feat: update dependencies and enhance chat functionality

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

No files matched your search

+391 -49
View File
@@ -5,30 +5,63 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { MessageSquare, Settings, Server, Moon, Sun, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { MessageSquare, Settings, Server, Moon, Sun, PanelLeftClose, PanelLeftOpen, Loader2 } from "lucide-react";
import { ChatInput } from "@/components/chat/chat-input";
import { ChatMessage } from "@/components/chat/chat-message";
import { SystemPanel } from "@/components/chat/system-panel";
import { PromptGeneratorDialog } from "@/components/chat/prompt-generator";
import { SidebarList } from "@/components/chat/sidebar-list";
import { useChat } from "@/hooks/use-chat";
import { useLocalModels } from "@/hooks/use-local-models";
import { useState, useEffect, useRef } from "react";
import { useChatStorage } from "@/hooks/use-chat-storage";
import { useAutoLabelingModel } from "@/hooks/use-auto-labeling-model";
import { useWebSearch } from "@/hooks/use-web-search";
import { SearchProgress } from "@/components/chat/search-progress";
import { ThinkingIndicator, ThinkingStep } from "@/components/chat/thinking-indicator";
import { useQueryGenerator } from "@/hooks/use-query-generator";
import { useSettingsStore } from "@/store/settings-store";
import { invoke } from "@tauri-apps/api/core";
import { useState, useEffect, useRef, useCallback } from "react";
import { useTheme } from "next-themes";
import { ImperativePanelHandle } from "react-resizable-panels";
import { sanitizeWebSources } from "@/lib/sanitize-web-content";
// @ts-ignore
import defaultFormatPrompt from "@/data/prompts/default-format.md";
export default function ChatPage() {
const { messages, sendMessage, isLoading, stop } = useChat();
const { messages, setMessages, sendMessage, isLoading, stop, clearChat } = useChat();
const { models } = useLocalModels();
const { theme, setTheme } = useTheme();
const {
sessions,
currentSessionId,
setCurrentSessionId,
loadSessionHistory,
loadSessions,
saveSession,
deleteSession,
isGeneratingTitle
} = useChatStorage();
const { isDownloading, progress } = useAutoLabelingModel();
const webSearch = useWebSearch();
const { generateQuery, isGenerating: isGeneratingQuery } = useQueryGenerator();
const settings = useSettingsStore();
const [selectedModel, setSelectedModel] = useState("");
// Initialize with default format prompt
const [systemPrompt, setSystemPrompt] = useState(defaultFormatPrompt || "Você é um assistente útil e prestativo.");
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [isChatsSidebarCollapsed, setIsChatsSidebarCollapsed] = useState(false);
const [thinkingStep, setThinkingStep] = useState<ThinkingStep | null>(null);
const sidebarRef = useRef<ImperativePanelHandle>(null);
const chatsSidebarRef = useRef<ImperativePanelHandle>(null);
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSavedMessagesRef = useRef<number>(0);
const messagesEndRef = useRef<HTMLDivElement>(null);
// Auto-select first model
useEffect(() => {
@@ -37,15 +70,226 @@ export default function ChatPage() {
}
}, [models, selectedModel]);
const handleSend = (content: string) => {
// Auto-scroll para o final durante streaming
useEffect(() => {
if (messagesEndRef.current && (isLoading || messages.length > 0)) {
// Pequeno delay para garantir que o DOM foi atualizado
setTimeout(() => {
messagesEndRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'end'
});
}, 100);
}
}, [messages, isLoading]);
// Save session when loading finishes (with debounce to avoid race conditions)
useEffect(() => {
// Clear any pending save
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
// Only save if:
// 1. Not currently loading
// 2. We have a session ID
// 3. We have messages
// 4. Messages actually changed (by count or reference)
if (!isLoading && currentSessionId && messages.length > 0) {
// Check if messages actually changed (simple length check, could be improved)
const currentMessageCount = messages.length;
if (currentMessageCount !== lastSavedMessagesRef.current) {
// Small delay to ensure metadata processing is complete
saveTimeoutRef.current = setTimeout(() => {
lastSavedMessagesRef.current = currentMessageCount;
saveSession(currentSessionId, messages);
}, 100);
}
}
// Cleanup timeout on unmount or dependency change
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, [isLoading, currentSessionId, messages, saveSession]);
const handleSend = async (content: string) => {
if (!selectedModel) return;
sendMessage(content, selectedModel, systemPrompt);
// Generate ID if new session (but don't create card yet - wait for title)
if (!currentSessionId) {
const newId = crypto.randomUUID();
setCurrentSessionId(newId);
// Card will be created after title is generated (after first AI response)
}
// ========== LÓGICA DE AGENTE DE PESQUISA ==========
let webContext = '';
let searchQuery = '';
let scrapedSources: any[] = [];
// Iniciar indicador de pensamento
setThinkingStep('analyzing');
if (webSearch.isEnabled) {
try {
webSearch.reset(); // Resetar estado anterior
// Passo 1: Gerar query otimizada
setThinkingStep('analyzing');
searchQuery = await generateQuery(content, selectedModel);
// Passo 2: Se precisar de busca, executar
if (searchQuery && searchQuery !== 'NO_SEARCH') {
setThinkingStep('searching');
// Buscar fontes usando configurações do store
const maxResults = settings.webSearch.maxResults;
const excludedDomains = settings.webSearch.excludedDomains;
scrapedSources = await webSearch.search(searchQuery, maxResults, excludedDomains);
// Atualizar indicador enquanto lê fontes
setThinkingStep('reading');
if (scrapedSources.length > 0) {
// Sanitizar e combinar markdown de todas as fontes
// Priorizamos o markdown completo (não apenas snippets) para garantir densidade de informação
// O scraper Rust já filtra conteúdo muito curto (< 200 chars) automaticamente
// Sanitização previne prompt injection e limita tamanho
webContext = sanitizeWebSources(scrapedSources, {
maxLength: 8000, // ~8k chars por fonte (ajustável)
removeControlChars: true,
removeHiddenText: true,
});
// Log para debug: verificar tamanho do contexto gerado
const contextLength = webContext.length;
console.debug(`Contexto web gerado (sanitizado): ${contextLength} caracteres de ${scrapedSources.length} fontes`);
}
}
} catch (error) {
console.error('Erro na pesquisa web:', error);
// Continuar mesmo se a pesquisa falhar
}
}
// Passo 3: Formular resposta
setThinkingStep('formulating');
// ========== MONTAR SYSTEM PROMPT COM CONTEXTO TEMPORAL ==========
// Usar timezone do sistema do usuário (não fixo)
const now = new Date();
const currentDateTime = now.toLocaleString('pt-BR', {
dateStyle: 'full',
timeStyle: 'short',
// Não especificar timeZone - usar o do sistema
});
// Formato adicional mais explícito para garantir que a IA entenda
const currentDateISO = now.toISOString();
const currentDateExplicit = `${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()} ${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`;
let enhancedSystemPrompt = systemPrompt;
// Adicionar contexto temporal e web
if (webContext) {
enhancedSystemPrompt = `${systemPrompt}
## ⚠️ DATA E HORA ATUAL DO SISTEMA (USE ESTA DATA EXATA)
**DATA/HORA FORMATADA:** ${currentDateTime}
**DATA/HORA NUMÉRICA:** ${currentDateExplicit}
**ISO 8601:** ${currentDateISO}
**REGRA CRÍTICA:** Você DEVE usar EXATAMENTE esta data e hora. NÃO invente ou calcule datas diferentes. Se o sistema diz que é ${currentDateTime}, então é ${currentDateTime}. Não use "amanhã" ou "ontem" a menos que seja explicitamente solicitado pelo usuário.
## INSTRUÇÕES ESPECÍFICAS PARA RESPOSTAS COM CONTEXTO WEB
1. **Contextualização Temporal Obrigatória:**
- Inicie respostas sobre notícias ou eventos citando a data atual EXATA fornecida acima.
- Use EXATAMENTE: "${currentDateTime}" (não invente outra data).
- Exemplo: "Com base nas notícias de ${currentDateTime}, os principais destaques são..."
2. **Densidade e Profundidade:**
- NUNCA responda com listas de tópicos curtos e vagos (ex: "Houve um protesto").
- Cada ponto deve conter detalhes específicos: Números, Nomes Próprios, Locais e Citações Diretas do contexto.
- Explique o *contexto* da notícia, não apenas a manchete.
- Prefira parágrafos explicativos com "quem, quando, onde, porquê".
3. **Uso Estrito de Fontes:**
- Use SOMENTE as informações do bloco [CONTEXTO WEB RECUPERADO] abaixo para fatos recentes. Não alucine.
- Se o contexto trouxer múltiplas notícias diferentes, agrupe-as por temas usando títulos Markdown (ex: "## Economia", "## Política").
- Cite as fontes usando [1], [2], [3] ao final das frases quando usar informações do contexto web.
- Se o contexto não for suficiente para responder completamente, diga isso claramente.
4. **Formato Jornalístico:**
- Use títulos Markdown (##) para separar seções temáticas.
- Texto corrido para explicações detalhadas.
- Bullets apenas para listar dados brutos (números, estatísticas).
## CONTEXTO WEB RECUPERADO
${webContext}
---
`;
} else {
// Mesmo sem contexto web, adicionar data/hora e instruções temporais
enhancedSystemPrompt = `${systemPrompt}
## ⚠️ DATA E HORA ATUAL DO SISTEMA (USE ESTA DATA EXATA)
**DATA/HORA FORMATADA:** ${currentDateTime}
**DATA/HORA NUMÉRICA:** ${currentDateExplicit}
**ISO 8601:** ${currentDateISO}
**REGRA CRÍTICA:** Você DEVE usar EXATAMENTE esta data e hora. NÃO invente ou calcule datas diferentes. Se o sistema diz que é ${currentDateTime}, então é ${currentDateTime}.
Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente a data EXATA fornecida acima (${currentDateTime}). Forneça detalhes ricos (nomes, valores, locais) extraídos do seu conhecimento. Evite resumos de uma linha - prefira explicações detalhadas e contextualizadas.
---
`;
}
await sendMessage(content, selectedModel, enhancedSystemPrompt);
// Marcar como completo quando começar a streamar
setTimeout(() => setThinkingStep('complete'), 100);
};
const toggleSidebar = () => {
const panel = sidebarRef.current;
const handleSelectSession = async (id: string) => {
if (id === currentSessionId) return;
// Clear any pending saves
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = null;
}
const history = await loadSessionHistory(id);
setMessages(history);
setCurrentSessionId(id);
lastSavedMessagesRef.current = history.length;
};
const handleNewChat = () => {
stop();
clearChat();
setCurrentSessionId(null);
// Reset save tracking
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = null;
}
lastSavedMessagesRef.current = 0;
};
const toggleChatsSidebar = () => {
const panel = chatsSidebarRef.current;
if (panel) {
if (isSidebarCollapsed) {
if (isChatsSidebarCollapsed) {
panel.expand();
} else {
panel.collapse();
@@ -59,15 +303,10 @@ export default function ChatPage() {
<ResizablePanelGroup direction="horizontal" className="flex-1">
<ResizablePanel
ref={sidebarRef}
defaultSize={4}
minSize={4}
maxSize={15}
collapsible={true}
collapsedSize={0}
onCollapse={() => setIsSidebarCollapsed(true)}
onExpand={() => setIsSidebarCollapsed(false)}
className="border-r flex flex-col items-center py-4 gap-4 bg-muted/20 min-w-[60px]"
maxSize={4}
className="border-r flex flex-col items-center py-4 gap-4 bg-muted/20 w-[60px]"
>
<Button variant="ghost" size="icon" className="rounded-lg bg-primary/10 text-primary">
<MessageSquare className="w-5 h-5" />
@@ -91,23 +330,54 @@ export default function ChatPage() {
<ResizableHandle />
{/* Sessions List */}
<ResizablePanel
ref={chatsSidebarRef}
defaultSize={15}
minSize={10}
maxSize={25}
collapsible={true}
collapsedSize={0}
onCollapse={() => setIsChatsSidebarCollapsed(true)}
onExpand={() => setIsChatsSidebarCollapsed(false)}
className="min-w-[200px]"
>
<SidebarList
sessions={sessions}
currentSessionId={currentSessionId}
onSelectSession={handleSelectSession}
onDeleteSession={deleteSession}
onNewChat={handleNewChat}
/>
</ResizablePanel>
<ResizableHandle />
{/* Main Chat Area */}
<ResizablePanel defaultSize={75} minSize={50}>
<ResizablePanel defaultSize={60} minSize={40}>
<div className="h-full flex flex-col">
{/* Header */}
<div className="h-14 border-b flex items-center px-4 justify-between bg-background/50 backdrop-blur gap-4">
<div className="flex items-center gap-2">
{isSidebarCollapsed && (
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="h-8 w-8">
{isChatsSidebarCollapsed ? (
<Button variant="ghost" size="icon" onClick={toggleChatsSidebar} className="h-8 w-8">
<PanelLeftOpen className="w-4 h-4" />
</Button>
)}
{!isSidebarCollapsed && (
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="h-8 w-8 text-muted-foreground">
) : (
<Button variant="ghost" size="icon" onClick={toggleChatsSidebar} className="h-8 w-8 text-muted-foreground">
<PanelLeftClose className="w-4 h-4" />
</Button>
)}
<div className="font-semibold">Chat</div>
<div className="font-semibold flex items-center gap-2">
Chat
{isGeneratingTitle && <Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />}
{isDownloading && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Loader2 className="w-3 h-3 animate-spin" />
{progress}
</span>
)}
</div>
</div>
<div className="flex-1 max-w-[300px]">
@@ -134,24 +404,93 @@ export default function ChatPage() {
<p>Inicie uma conversa com {selectedModel}</p>
</div>
) : (
<div className="flex flex-col pb-4">
{messages.map((msg, i) => (
<ChatMessage key={i} message={msg} />
))}
{isLoading && (
<div className="flex flex-col pb-4 max-w-4xl mx-auto w-full">
{messages.map((msg, i) => {
// Verificar se a mensagem anterior teve busca web
const hasWebSources = i > 0 &&
messages[i - 1].role === 'user' &&
webSearch.scrapedSources.length > 0 &&
webSearch.status === 'completed';
return (
<div key={i}>
<ChatMessage message={msg} />
{/* Mostrar fontes após resposta do assistente que usou web search */}
{msg.role === 'assistant' && hasWebSources && (
<div className="px-6 pb-4">
<div className="pt-2 border-t border-muted">
<p className="text-xs font-medium text-muted-foreground mb-2">
Fontes consultadas:
</p>
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent">
{webSearch.scrapedSources.map((source, idx) => (
<a
key={idx}
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="flex-shrink-0 flex items-center gap-2 px-3 py-2 rounded-md bg-muted/50 hover:bg-muted/80 transition-colors border border-muted text-xs max-w-[200px]"
>
<img
src={`https://www.google.com/s2/favicons?domain=${new URL(source.url).hostname}&sz=16`}
alt=""
className="w-4 h-4 rounded"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
<span className="truncate font-medium">
{source.title || `Fonte ${idx + 1}`}
</span>
</a>
))}
</div>
</div>
</div>
)}
</div>
);
})}
{/* Web Search Progress - mostrar antes da resposta da IA */}
{(webSearch.status === 'searching' ||
webSearch.status === 'scraping' ||
webSearch.status === 'completed' ||
webSearch.status === 'error') && (
<SearchProgress
status={webSearch.status}
query={webSearch.currentQuery}
sources={webSearch.scrapedSources}
error={webSearch.error}
/>
)}
{isLoading && thinkingStep === 'complete' && (
<div className="px-6 py-4 text-xs text-muted-foreground animate-pulse">
Gerando resposta...
</div>
)}
</div>
)}
{/* Elemento invisível para scroll automático */}
<div ref={messagesEndRef} className="h-1" />
</div>
{/* Thinking Indicator */}
{thinkingStep && thinkingStep !== 'complete' && (
<ThinkingIndicator
currentStep={thinkingStep}
searchQuery={webSearch.currentQuery}
sourcesRead={webSearch.scrapedSources.length}
totalSources={5}
/>
)}
{/* Input */}
<ChatInput
onSend={handleSend}
onStop={stop}
isLoading={isLoading}
isLoading={isLoading}
webSearchEnabled={webSearch.isEnabled}
onWebSearchToggle={webSearch.setEnabled}
/>
</div>
</ResizablePanel>
@@ -159,8 +498,8 @@ export default function ChatPage() {
<ResizableHandle withHandle />
{/* Right Sidebar (Settings & Monitor) */}
<ResizablePanel defaultSize={25} minSize={20} maxSize={40} className="bg-muted/10">
<Tabs defaultValue="params" className="h-full flex flex-col">
<ResizablePanel defaultSize={20} minSize={15} maxSize={30} className="bg-muted/10">
<Tabs defaultValue="params" className="h-full flex flex-col overflow-hidden">
<div className="border-b px-4">
<TabsList className="w-full justify-start h-12 bg-transparent p-0 gap-4">
<TabsTrigger
@@ -178,28 +517,31 @@ export default function ChatPage() {
</TabsList>
</div>
<TabsContent value="params" className="flex-1 p-4 space-y-6 overflow-y-auto m-0">
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">System Prompt</label>
<PromptGeneratorDialog
defaultModel={selectedModel}
onPromptGenerated={setSystemPrompt}
<TabsContent value="params" className="flex-1 p-4 space-y-6 overflow-y-auto overflow-x-hidden m-0">
<div className="space-y-6">
{/* System Prompt */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">System Prompt</label>
<PromptGeneratorDialog
defaultModel={selectedModel}
onPromptGenerated={setSystemPrompt}
/>
</div>
<Textarea
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
className="min-h-[300px] resize-none font-mono text-sm"
placeholder="Defina como a IA deve se comportar..."
/>
<p className="text-xs text-muted-foreground">
Instruções globais para o comportamento do modelo.
</p>
</div>
<Textarea
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
className="min-h-[300px] resize-none font-mono text-sm"
placeholder="Defina como a IA deve se comportar..."
/>
<p className="text-xs text-muted-foreground">
Instruções globais para o comportamento do modelo.
</p>
</div>
</TabsContent>
<TabsContent value="system" className="flex-1 m-0 overflow-hidden">
<TabsContent value="system" className="flex-1 m-0 overflow-y-auto overflow-x-hidden">
<SystemPanel />
</TabsContent>
</Tabs>
+638
View File
@@ -0,0 +1,638 @@
'use client';
import { useState, useEffect } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Slider } from '@/components/ui/slider';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useSettingsStore } from '@/store/settings-store';
import { DomainTagsInput } from '@/components/settings/domain-tags-input';
import { invoke } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { CheckCircle2, XCircle, Loader2, Download, Trash2, Copy, ExternalLink, Plus, X, BookOpen, GraduationCap, Newspaper, Code, DollarSign } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
interface Model {
name: string;
modified_at: string;
size: number;
}
export default function SettingsPage() {
const settings = useSettingsStore();
const { toast } = useToast();
const [isCheckingConnection, setIsCheckingConnection] = useState(false);
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [availableModels, setAvailableModels] = useState<Model[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [storagePath, setStoragePath] = useState<string>('');
const [showClearDialog, setShowClearDialog] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [isClearing, setIsClearing] = useState(false);
// Load models and storage path on mount
useEffect(() => {
loadModels();
loadStoragePath();
}, [settings.ollamaUrl]);
const loadModels = async () => {
setIsLoadingModels(true);
try {
const response = await fetch(`${settings.ollamaUrl}/api/tags`);
if (response.ok) {
const data = await response.json();
setAvailableModels(data.models || []);
} else {
setAvailableModels([]);
}
} catch (error) {
console.error('Failed to load models:', error);
setAvailableModels([]);
} finally {
setIsLoadingModels(false);
}
};
const loadStoragePath = async () => {
try {
const path = await invoke<string>('get_app_data_dir');
setStoragePath(path);
} catch (error) {
console.error('Failed to load storage path:', error);
setStoragePath('N/A');
}
};
const checkConnection = async () => {
setIsCheckingConnection(true);
setConnectionStatus('idle');
try {
const response = await fetch(`${settings.ollamaUrl}/api/tags`, {
method: 'HEAD',
});
if (response.ok) {
setConnectionStatus('success');
toast({
title: 'Conexão bem-sucedida',
description: 'Ollama está acessível neste endpoint.',
});
// Reload models after successful connection
loadModels();
} else {
setConnectionStatus('error');
toast({
title: 'Erro de conexão',
description: `Status: ${response.status}`,
variant: 'destructive',
});
}
} catch (error) {
setConnectionStatus('error');
toast({
title: 'Erro de conexão',
description: error instanceof Error ? error.message : 'Falha ao conectar',
variant: 'destructive',
});
} finally {
setIsCheckingConnection(false);
}
};
const handleExportChats = async () => {
setIsExporting(true);
try {
await invoke('export_chat_sessions');
toast({
title: 'Exportação concluída',
description: 'Todos os chats foram exportados com sucesso.',
});
} catch (error) {
console.error('Failed to export chats:', error);
toast({
title: 'Erro na exportação',
description: error instanceof Error ? error.message : 'Falha ao exportar',
variant: 'destructive',
});
} finally {
setIsExporting(false);
}
};
const handleClearHistory = async () => {
setIsClearing(true);
try {
await invoke('clear_chat_history');
toast({
title: 'Histórico apagado',
description: 'Todas as conversas foram removidas.',
});
setShowClearDialog(false);
} catch (error) {
console.error('Failed to clear history:', error);
toast({
title: 'Erro ao apagar',
description: error instanceof Error ? error.message : 'Falha ao apagar histórico',
variant: 'destructive',
});
} finally {
setIsClearing(false);
}
};
const copyStoragePath = async () => {
try {
await navigator.clipboard.writeText(storagePath);
toast({
title: 'Caminho copiado',
description: 'O caminho foi copiado para a área de transferência.',
});
} catch (error) {
console.error('Failed to copy path:', error);
}
};
return (
<div className="container mx-auto py-8 max-w-4xl">
<div className="mb-6">
<h1 className="text-3xl font-bold">Configurações</h1>
<p className="text-muted-foreground mt-2">
Gerencie as preferências do OllaHub
</p>
</div>
<Tabs defaultValue="ai" className="space-y-6">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="ai">AI & Models</TabsTrigger>
<TabsTrigger value="web">Web Search</TabsTrigger>
<TabsTrigger value="sources">Sources & Knowledge</TabsTrigger>
<TabsTrigger value="storage">Data & Storage</TabsTrigger>
</TabsList>
{/* Tab: AI & Models */}
<TabsContent value="ai" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Conexão Ollama</CardTitle>
<CardDescription>
Configure o endpoint do Ollama local
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="ollama-url">Ollama Endpoint</Label>
<div className="flex gap-2">
<Input
id="ollama-url"
value={settings.ollamaUrl}
onChange={(e) => settings.setOllamaUrl(e.target.value)}
placeholder="http://localhost:11434"
/>
<Button
onClick={checkConnection}
disabled={isCheckingConnection}
variant="outline"
>
{isCheckingConnection ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : connectionStatus === 'success' ? (
<CheckCircle2 className="w-4 h-4 text-green-500" />
) : connectionStatus === 'error' ? (
<XCircle className="w-4 h-4 text-red-500" />
) : (
'Verificar'
)}
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="model-select">Modelo Padrão</Label>
{isLoadingModels ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
Carregando modelos...
</div>
) : availableModels.length > 0 ? (
<Select
value={settings.selectedModel}
onValueChange={settings.setSelectedModel}
>
<SelectTrigger id="model-select">
<SelectValue placeholder="Selecione um modelo..." />
</SelectTrigger>
<SelectContent>
{availableModels.map((model) => (
<SelectItem key={model.name} value={model.name}>
{model.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id="model-input"
value={settings.selectedModel}
onChange={(e) => settings.setSelectedModel(e.target.value)}
placeholder="Digite o nome do modelo..."
/>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>System Prompt</CardTitle>
<CardDescription>
Defina a personalidade e comportamento padrão da IA
</CardDescription>
</CardHeader>
<CardContent>
<Textarea
value={settings.systemPrompt}
onChange={(e) => settings.setSystemPrompt(e.target.value)}
className="min-h-[300px] font-mono text-sm"
placeholder="Você é um assistente útil..."
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Context Window</CardTitle>
<CardDescription>
Tamanho máximo do contexto (tokens)
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>2048</span>
<span className="font-medium">{settings.contextWindow.toLocaleString()}</span>
<span>32768</span>
</div>
<Slider
value={[settings.contextWindow]}
onValueChange={([value]) => settings.setContextWindow(value)}
min={2048}
max={32768}
step={512}
/>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Tab: Web Search */}
<TabsContent value="web" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Web Search</CardTitle>
<CardDescription>
Configure o sistema de busca e scraping na web
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="web-search-enabled">Ativar Web Search</Label>
<p className="text-xs text-muted-foreground">
Busca automática na web antes de responder
</p>
</div>
<Switch
id="web-search-enabled"
checked={settings.webSearch.enabled}
onCheckedChange={settings.setWebSearchEnabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="max-results">Máximo de Resultados</Label>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>1</span>
<span className="font-medium">{settings.webSearch.maxResults}</span>
<span>10</span>
</div>
<Slider
value={[settings.webSearch.maxResults]}
onValueChange={([value]) => settings.setWebSearchMaxResults(value)}
min={1}
max={10}
step={1}
disabled={!settings.webSearch.enabled}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="timeout">Timeout (segundos)</Label>
<Input
id="timeout"
type="number"
min={5}
max={30}
value={settings.webSearch.timeout}
onChange={(e) => settings.setWebSearchTimeout(Number(e.target.value))}
disabled={!settings.webSearch.enabled}
/>
<p className="text-xs text-muted-foreground">
Tempo máximo para carregar cada página
</p>
</div>
<DomainTagsInput
domains={settings.webSearch.excludedDomains}
onAdd={settings.addExcludedDomain}
onRemove={settings.removeExcludedDomain}
/>
</CardContent>
</Card>
</TabsContent>
{/* Tab: Sources & Knowledge */}
<TabsContent value="sources" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Fontes de Conhecimento</CardTitle>
<CardDescription>
Configure categorias de busca e sites customizados para pesquisa avançada
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Performance Settings */}
<div className="space-y-4 pb-4 border-b">
<div className="space-y-2">
<Label htmlFor="max-concurrent">Abas Simultâneas (Performance)</Label>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>1</span>
<span className="font-medium">{settings.webSearch.maxConcurrentTabs}</span>
<span>10</span>
</div>
<Slider
value={[settings.webSearch.maxConcurrentTabs]}
onValueChange={([value]) => settings.setWebSearchMaxConcurrentTabs(value)}
min={1}
max={10}
step={1}
/>
</div>
<p className="text-xs text-muted-foreground">
Número máximo de páginas processadas simultaneamente (mais = mais rápido, mas mais uso de CPU)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="depth-research">Profundidade da Pesquisa</Label>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>Rápido (5)</span>
<span className="font-medium">{settings.webSearch.totalSourcesLimit}</span>
<span>Profundo (40)</span>
</div>
<Slider
value={[settings.webSearch.totalSourcesLimit]}
onValueChange={([value]) => settings.setWebSearchTotalSourcesLimit(value)}
min={5}
max={40}
step={5}
/>
</div>
<p className="text-xs text-muted-foreground">
Número total de fontes a buscar. Mais fontes = mais tempo, mas resultados mais completos.
</p>
</div>
</div>
{/* Categories */}
<div className="space-y-4">
<Label>Categorias de Busca</Label>
<div className="grid gap-4">
{settings.webSearch.categories.map((category) => {
const iconMap: Record<string, React.ReactNode> = {
academic: <GraduationCap className="w-4 h-4" />,
news: <Newspaper className="w-4 h-4" />,
tech: <Code className="w-4 h-4" />,
finance: <DollarSign className="w-4 h-4" />,
};
return (
<Card key={category.id} className={category.enabled ? 'border-primary' : ''}>
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3 flex-1">
<div className="mt-1">
{iconMap[category.id] || <BookOpen className="w-4 h-4" />}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<Label htmlFor={`category-${category.id}`} className="font-semibold">
{category.name}
</Label>
<Switch
id={`category-${category.id}`}
checked={category.enabled}
onCheckedChange={() => settings.toggleCategory(category.id)}
/>
</div>
<div className="flex flex-wrap gap-1.5">
{category.baseSites.map((site) => (
<Badge key={site} variant="secondary" className="text-xs">
{site}
</Badge>
))}
</div>
<p className="text-xs text-muted-foreground mt-2">
{category.baseSites.length} sites curados
</p>
</div>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
{/* Custom Sites */}
<div className="space-y-4 pt-4 border-t">
<Label>Sites Customizados</Label>
<p className="text-xs text-muted-foreground">
Adicione seus próprios domínios favoritos para busca direcionada
</p>
<div className="flex gap-2">
<Input
id="custom-site-input"
placeholder="exemplo.com"
onKeyDown={(e) => {
if (e.key === 'Enter') {
const input = e.currentTarget;
const value = input.value.trim();
if (value) {
settings.addCustomSite(value);
input.value = '';
}
}
}}
/>
<Button
type="button"
variant="outline"
onClick={() => {
const input = document.getElementById('custom-site-input') as HTMLInputElement;
const value = input?.value.trim();
if (value) {
settings.addCustomSite(value);
input.value = '';
}
}}
>
<Plus className="w-4 h-4" />
</Button>
</div>
{settings.webSearch.userCustomSites.length > 0 && (
<div className="flex flex-wrap gap-2">
{settings.webSearch.userCustomSites.map((site) => (
<Badge key={site} variant="outline" className="text-sm">
{site}
<button
onClick={() => settings.removeCustomSite(site)}
className="ml-2 hover:text-destructive"
>
<X className="w-3 h-3" />
</button>
</Badge>
))}
</div>
)}
</div>
</CardContent>
</Card>
</TabsContent>
{/* Tab: Data & Storage */}
<TabsContent value="storage" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Armazenamento</CardTitle>
<CardDescription>
Gerencie os dados salvos localmente
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label>Localização dos Dados</Label>
<div className="flex items-center gap-2">
<Input
value={storagePath}
readOnly
className="font-mono text-sm"
/>
<Button
variant="outline"
size="icon"
onClick={copyStoragePath}
title="Copiar caminho"
>
<Copy className="w-4 h-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
As conversas são salvas como arquivos JSON neste diretório
</p>
</div>
<div className="space-y-4 pt-4 border-t">
<div className="space-y-2">
<Label>Ações</Label>
<div className="flex gap-2">
<Button
onClick={handleExportChats}
disabled={isExporting}
variant="outline"
>
{isExporting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Exportando...
</>
) : (
<>
<Download className="w-4 h-4 mr-2" />
Exportar Todos os Chats
</>
)}
</Button>
<Button
onClick={() => setShowClearDialog(true)}
variant="destructive"
>
<Trash2 className="w-4 h-4 mr-2" />
Apagar Histórico
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Clear History Dialog */}
<Dialog open={showClearDialog} onOpenChange={setShowClearDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirmar Exclusão</DialogTitle>
<DialogDescription>
Esta ação não pode ser desfeita. Todos os chats serão permanentemente removidos.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowClearDialog(false)}
disabled={isClearing}
>
Cancelar
</Button>
<Button
variant="destructive"
onClick={handleClearHistory}
disabled={isClearing}
>
{isClearing ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Apagando...
</>
) : (
'Confirmar Exclusão'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+457
View File
@@ -0,0 +1,457 @@
'use client';
import { useState, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Plus, Trash2, Clock, Play, Pause } from 'lucide-react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
interface Task {
id: string;
label: string;
cron_schedule: string;
action: {
type: 'search_and_summarize' | 'just_ping' | 'custom_prompt';
query?: string;
model?: string;
max_results?: number;
message?: string;
prompt?: string;
};
enabled: boolean;
last_run: string | null;
created_at: string;
updated_at: string;
}
export default function TasksPage() {
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [isDialogOpen, setIsDialogOpen] = useState(false);
useEffect(() => {
loadTasks();
}, []);
const loadTasks = async () => {
try {
setLoading(true);
const loaded = await invoke<Task[]>('list_tasks');
setTasks(loaded);
} catch (error) {
console.error('Erro ao carregar tasks:', error);
} finally {
setLoading(false);
}
};
const toggleTask = async (id: string, currentEnabled: boolean) => {
try {
await invoke('toggle_task', { id, enabled: !currentEnabled });
await loadTasks();
} catch (error) {
console.error('Erro ao alternar task:', error);
}
};
const deleteTask = async (id: string) => {
if (!confirm('Tem certeza que deseja excluir esta task?')) {
return;
}
try {
await invoke('delete_task', { id });
await loadTasks();
} catch (error) {
console.error('Erro ao excluir task:', error);
}
};
const formatCron = (cron: string) => {
// Formatação básica de cron para exibição
const parts = cron.split(' ');
if (parts.length === 5) {
const [min, hour, day, month, weekday] = parts;
if (min === '0' && hour !== '*' && day === '*' && month === '*' && weekday === '*') {
return `Todo dia às ${hour}:00`;
}
if (min === '0' && hour === '8' && day === '*' && month === '*' && weekday === '*') {
return 'Todo dia às 8:00';
}
}
return cron;
};
const formatLastRun = (lastRun: string | null) => {
if (!lastRun) return 'Nunca';
try {
const date = new Date(lastRun);
return date.toLocaleString('pt-BR');
} catch {
return lastRun;
}
};
return (
<div className="container mx-auto p-6 max-w-6xl">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-bold">Tarefas Agendadas</h1>
<p className="text-muted-foreground mt-1">
Gerencie tarefas que executam automaticamente em segundo plano
</p>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="w-4 h-4 mr-2" />
Nova Task
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Criar Nova Task</DialogTitle>
<DialogDescription>
Configure uma tarefa para executar automaticamente
</DialogDescription>
</DialogHeader>
<TaskFormDialog
onSuccess={() => {
setIsDialogOpen(false);
loadTasks();
}}
/>
</DialogContent>
</Dialog>
</div>
{loading ? (
<div className="text-center py-12 text-muted-foreground">
Carregando tasks...
</div>
) : tasks.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground mb-4">
Nenhuma task agendada ainda.
</p>
<Button onClick={() => setIsDialogOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
Criar Primeira Task
</Button>
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{tasks.map(task => (
<Card key={task.id}>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="flex items-center gap-2">
{task.label}
{task.enabled ? (
<span className="text-xs bg-green-500/10 text-green-500 px-2 py-1 rounded">
Ativa
</span>
) : (
<span className="text-xs bg-gray-500/10 text-gray-500 px-2 py-1 rounded">
Pausada
</span>
)}
</CardTitle>
<CardDescription className="mt-2">
<div className="flex items-center gap-4 text-sm">
<span className="flex items-center gap-1">
<Clock className="w-4 h-4" />
{formatCron(task.cron_schedule)}
</span>
<span>Última execução: {formatLastRun(task.last_run)}</span>
</div>
</CardDescription>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<Label htmlFor={`toggle-${task.id}`} className="text-sm">
{task.enabled ? 'Ativa' : 'Pausada'}
</Label>
<Switch
id={`toggle-${task.id}`}
checked={task.enabled}
onCheckedChange={() => toggleTask(task.id, task.enabled)}
/>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => deleteTask(task.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="text-sm text-muted-foreground">
<p><strong>Tipo:</strong> {
task.action.type === 'search_and_summarize' ? 'Pesquisar e Resumir' :
task.action.type === 'just_ping' ? 'Notificação' :
'Prompt Customizado'
}</p>
{task.action.query && (
<p><strong>Query:</strong> {task.action.query}</p>
)}
{task.action.model && (
<p><strong>Modelo:</strong> {task.action.model}</p>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
function TaskFormDialog({ onSuccess }: { onSuccess: () => void }) {
const [label, setLabel] = useState('');
const [scheduleType, setScheduleType] = useState<'daily' | 'weekly' | 'custom'>('daily');
const [hour, setHour] = useState('8');
const [minute, setMinute] = useState('0');
const [customCron, setCustomCron] = useState('');
const [taskType, setTaskType] = useState<'search_and_summarize' | 'just_ping' | 'custom_prompt'>('search_and_summarize');
const [query, setQuery] = useState('');
const [model, setModel] = useState('llama3.2');
const [maxResults, setMaxResults] = useState(3);
const [message, setMessage] = useState('');
const [customPrompt, setCustomPrompt] = useState('');
const [submitting, setSubmitting] = useState(false);
const generateCron = (): string => {
if (scheduleType === 'custom') {
return customCron;
}
// Diário às hora:minuto
return `${minute} ${hour} * * *`;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
try {
let action: any;
if (taskType === 'search_and_summarize') {
action = {
type: 'search_and_summarize',
query,
model,
max_results: maxResults,
};
} else if (taskType === 'just_ping') {
action = {
type: 'just_ping',
message,
};
} else {
action = {
type: 'custom_prompt',
prompt: customPrompt,
model,
};
}
await invoke('create_task', {
label,
cronSchedule: generateCron(),
action,
});
onSuccess();
} catch (error) {
console.error('Erro ao criar task:', error);
alert('Erro ao criar task. Verifique o console para mais detalhes.');
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="label">Nome da Task</Label>
<Input
id="label"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="Ex: Resumo de Notícias Diário"
required
/>
</div>
<div>
<Label htmlFor="schedule-type">Frequência</Label>
<Select value={scheduleType} onValueChange={(v: any) => setScheduleType(v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="daily">Diário</SelectItem>
<SelectItem value="weekly">Semanal</SelectItem>
<SelectItem value="custom">Cron Customizado</SelectItem>
</SelectContent>
</Select>
</div>
{scheduleType === 'custom' ? (
<div>
<Label htmlFor="cron">Expressão Cron</Label>
<Input
id="cron"
value={customCron}
onChange={(e) => setCustomCron(e.target.value)}
placeholder="0 8 * * * (todo dia às 8h)"
required
/>
<p className="text-xs text-muted-foreground mt-1">
Formato: minuto hora dia mês dia-da-semana
</p>
</div>
) : (
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="hour">Hora</Label>
<Input
id="hour"
type="number"
min="0"
max="23"
value={hour}
onChange={(e) => setHour(e.target.value)}
required
/>
</div>
<div>
<Label htmlFor="minute">Minuto</Label>
<Input
id="minute"
type="number"
min="0"
max="59"
value={minute}
onChange={(e) => setMinute(e.target.value)}
required
/>
</div>
</div>
)}
<div>
<Label htmlFor="task-type">Tipo de Task</Label>
<Select value={taskType} onValueChange={(v: any) => setTaskType(v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="search_and_summarize">Pesquisar e Resumir</SelectItem>
<SelectItem value="just_ping">Apenas Notificação</SelectItem>
<SelectItem value="custom_prompt">Prompt Customizado</SelectItem>
</SelectContent>
</Select>
</div>
{taskType === 'search_and_summarize' && (
<>
<div>
<Label htmlFor="query">Query de Pesquisa</Label>
<Input
id="query"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Ex: notícias sobre inteligência artificial hoje"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="model">Modelo</Label>
<Input
id="model"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="llama3.2"
required
/>
</div>
<div>
<Label htmlFor="max-results">Máx. Resultados</Label>
<Input
id="max-results"
type="number"
min="1"
max="10"
value={maxResults}
onChange={(e) => setMaxResults(parseInt(e.target.value))}
required
/>
</div>
</div>
</>
)}
{taskType === 'just_ping' && (
<div>
<Label htmlFor="message">Mensagem</Label>
<Textarea
id="message"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Mensagem da notificação"
required
/>
</div>
)}
{taskType === 'custom_prompt' && (
<>
<div>
<Label htmlFor="prompt">Prompt</Label>
<Textarea
id="prompt"
value={customPrompt}
onChange={(e) => setCustomPrompt(e.target.value)}
placeholder="Seu prompt customizado aqui"
required
className="min-h-[100px]"
/>
</div>
<div>
<Label htmlFor="model-prompt">Modelo</Label>
<Input
id="model-prompt"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="llama3.2"
required
/>
</div>
</>
)}
<div className="flex justify-end gap-2 pt-4">
<Button type="submit" disabled={submitting}>
{submitting ? 'Criando...' : 'Criar Task'}
</Button>
</div>
</form>
);
}