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