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

+86 -27
View File
@@ -1,15 +1,29 @@
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Send, Square } from "lucide-react";
import { Send, Square, Globe, WifiOff } from "lucide-react";
import { useState, useRef, useEffect } from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface ChatInputProps {
onSend: (message: string) => void;
onStop: () => void;
isLoading: boolean;
webSearchEnabled?: boolean;
onWebSearchToggle?: (enabled: boolean) => void;
}
export function ChatInput({ onSend, onStop, isLoading }: ChatInputProps) {
export function ChatInput({
onSend,
onStop,
isLoading,
webSearchEnabled = true,
onWebSearchToggle,
}: ChatInputProps) {
const [input, setInput] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -36,31 +50,74 @@ export function ChatInput({ onSend, onStop, isLoading }: ChatInputProps) {
return (
<div className="p-4 border-t bg-background">
<div className="relative flex items-end gap-2 max-w-3xl mx-auto">
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Digite sua mensagem..."
className="min-h-[50px] max-h-[200px] resize-none pr-12 py-3"
rows={1}
/>
<div className="absolute right-2 bottom-2">
{isLoading ? (
<Button size="icon" variant="destructive" onClick={onStop} className="h-8 w-8">
<Square className="h-4 w-4 fill-current" />
</Button>
) : (
<Button
size="icon"
onClick={handleSend}
disabled={!input.trim()}
className="h-8 w-8"
>
<Send className="h-4 w-4" />
</Button>
)}
<div className="relative flex items-end gap-2 max-w-4xl mx-auto w-full">
<div className="flex-1 relative">
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Digite sua mensagem..."
className="min-h-[50px] max-h-[200px] resize-none py-3 pr-12"
rows={1}
/>
<div className="absolute right-2 bottom-2 flex items-center gap-2">
{onWebSearchToggle && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant={webSearchEnabled ? "default" : "ghost"}
onClick={() => {
if (!webSearchEnabled) {
// Ao ativar, não precisa de confirmação
onWebSearchToggle(true);
} else {
// Ao desativar, mostrar aviso
const confirmed = window.confirm(
"Atenção: Desativar a pesquisa web pode reduzir a qualidade das respostas para fatos recentes, especialmente em modelos menores. Deseja continuar?"
);
if (confirmed) {
onWebSearchToggle(false);
}
}
}}
className="h-8 w-8"
disabled={isLoading}
>
{webSearchEnabled ? (
<Globe className="h-4 w-4" />
) : (
<WifiOff className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{webSearchEnabled
? "Pesquisa web ativada"
: "Pesquisa web desativada"}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{isLoading ? (
<Button size="icon" variant="destructive" onClick={onStop} className="h-8 w-8">
<Square className="h-4 w-4 fill-current" />
</Button>
) : (
<Button
size="icon"
onClick={handleSend}
disabled={!input.trim()}
className="h-8 w-8"
>
<Send className="h-4 w-4" />
</Button>
)}
</div>
</div>
</div>
<div className="text-xs text-center text-muted-foreground mt-2">
@@ -70,3 +127,5 @@ export function ChatInput({ onSend, onStop, isLoading }: ChatInputProps) {
);
}
+24 -1
View File
@@ -28,7 +28,30 @@ export function ChatMessage({ message }: { message: Message }) {
remarkPlugins={[remarkGfm]}
components={markdownRenderers}
>
{message.content}
{(() => {
let content = message.content || '';
// Remove metadata tags
content = content.replace(/<metadata>[\s\S]*?<\/metadata>/gi, '').trim();
// Also remove common prefixes if they appear at the end
const prefixes = [
'Bloco oculto de metadados:',
'Metadata:',
'Metadados:',
'Hidden metadata block:',
'JSON metadata:',
'---'
];
for (const prefix of prefixes) {
const regex = new RegExp(`${prefix}\\s*$`, 'i');
if (regex.test(content)) {
content = content.replace(regex, '').trim();
}
}
return content || message.content;
})()}
</ReactMarkdown>
</div>
</div>
+2
View File
@@ -43,3 +43,5 @@ export function CodeBlock({ language, children }: CodeBlockProps) {
);
}
+2
View File
@@ -136,3 +136,5 @@ export const markdownRenderers: Components = {
),
};
+419
View File
@@ -0,0 +1,419 @@
'use client';
import { useState, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardAction } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Loader2, Play, Square, RefreshCw, Save } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { McpJsonEditor } from '@/components/chat/mcp-json-editor';
interface McpServerConfig {
command: string;
args: string[];
env?: Record<string, string>;
}
interface McpConfig {
mcpServers: Record<string, McpServerConfig>;
}
interface McpServerStatus {
name: string;
status: 'running' | 'stopped' | 'error';
pid?: number;
}
export function McpConfigPanel() {
const [config, setConfig] = useState<McpConfig>({ mcpServers: {} });
const [statuses, setStatuses] = useState<McpServerStatus[]>([]);
const [configJson, setConfigJson] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [configPath, setConfigPath] = useState('');
const { toast } = useToast();
useEffect(() => {
loadConfig();
loadStatus();
loadConfigPath();
// Refresh status every 5 seconds
const interval = setInterval(() => {
loadStatus();
}, 5000);
return () => clearInterval(interval);
}, []);
const loadConfig = async () => {
try {
setIsLoading(true);
const loadedConfig = await invoke<any>('load_mcp_config');
// Convert from Rust HashMap to TypeScript Record
// Always ensure minimum structure: { mcpServers: {} }
const config: McpConfig = {
mcpServers: loadedConfig.mcp_servers || loadedConfig.mcpServers || {}
};
setConfig(config);
setConfigJson(JSON.stringify(config, null, 2));
} catch (error) {
console.error('Failed to load MCP config:', error);
// On error, set minimum structure
const minConfig: McpConfig = { mcpServers: {} };
setConfig(minConfig);
setConfigJson(JSON.stringify(minConfig, null, 2));
toast({
title: 'Erro',
description: 'Falha ao carregar configuração MCP',
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};
const loadStatus = async () => {
try {
const serverStatuses = await invoke<McpServerStatus[]>('list_mcp_server_status');
setStatuses(serverStatuses);
} catch (error) {
console.error('Failed to load MCP status:', error);
}
};
const loadConfigPath = async () => {
try {
const path = await invoke<string>('get_mcp_config_path_command');
setConfigPath(path);
} catch (error) {
console.error('Failed to load config path:', error);
}
};
const saveConfig = async () => {
try {
setIsSaving(true);
// Trim whitespace from JSON string
const trimmedJson = configJson.trim();
// Validate JSON structure
if (!trimmedJson) {
toast({
title: 'Erro',
description: 'JSON vazio. Por favor, insira uma configuração válida.',
variant: 'destructive',
});
setIsSaving(false);
return;
}
let parsedConfig: McpConfig;
try {
parsedConfig = JSON.parse(trimmedJson);
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Erro desconhecido ao fazer parse do JSON';
console.error('JSON parse error:', e);
toast({
title: 'Erro de JSON',
description: `JSON inválido: ${errorMessage}`,
variant: 'destructive',
});
setIsSaving(false);
return;
}
// Validate required structure
if (!parsedConfig || typeof parsedConfig !== 'object') {
toast({
title: 'Erro',
description: 'Configuração inválida: deve ser um objeto JSON.',
variant: 'destructive',
});
setIsSaving(false);
return;
}
// Ensure mcpServers exists and is an object
if (!parsedConfig.mcpServers || typeof parsedConfig.mcpServers !== 'object') {
parsedConfig.mcpServers = {};
}
// Always ensure minimum structure: { mcpServers: {} }
const normalizedConfig: McpConfig = {
mcpServers: parsedConfig.mcpServers || {}
};
// The Rust struct uses serde(rename = "mcpServers") so we can pass it directly
// No need to convert mcpServers to mcp_servers
await invoke('save_mcp_config', { config: normalizedConfig });
setConfig(normalizedConfig);
// Update JSON to reflect normalized structure
setConfigJson(JSON.stringify(normalizedConfig, null, 2));
toast({
title: 'Sucesso',
description: 'Configuração MCP salva com sucesso',
});
// Reload status after saving
loadStatus();
} catch (error) {
console.error('Failed to save MCP config:', error);
const errorMessage = error instanceof Error ? error.message : 'Erro desconhecido';
toast({
title: 'Erro',
description: `Falha ao salvar configuração MCP: ${errorMessage}`,
variant: 'destructive',
});
} finally {
setIsSaving(false);
}
};
const startServer = async (name: string) => {
try {
const serverConfig = config.mcpServers[name];
if (!serverConfig) {
toast({
title: 'Erro',
description: `Servidor '${name}' não encontrado na configuração`,
variant: 'destructive',
});
return;
}
await invoke('start_mcp_server', { name, config: serverConfig });
toast({
title: 'Sucesso',
description: `Servidor '${name}' iniciado`,
});
loadStatus();
} catch (error) {
console.error('Failed to start server:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
toast({
title: 'Erro ao Iniciar Servidor',
description: errorMessage || `Falha ao iniciar servidor '${name}'`,
variant: 'destructive',
});
}
};
const stopServer = async (name: string) => {
try {
await invoke('stop_mcp_server', { name });
toast({
title: 'Sucesso',
description: `Servidor '${name}' parado`,
});
loadStatus();
} catch (error) {
console.error('Failed to stop server:', error);
toast({
title: 'Erro',
description: `Falha ao parar servidor '${name}'`,
variant: 'destructive',
});
}
};
const restartServer = async (name: string) => {
try {
await invoke('restart_mcp_server', { name });
toast({
title: 'Sucesso',
description: `Servidor '${name}' reiniciado`,
});
loadStatus();
} catch (error) {
console.error('Failed to restart server:', error);
toast({
title: 'Erro',
description: `Falha ao reiniciar servidor '${name}'`,
variant: 'destructive',
});
}
};
const restartAll = async () => {
try {
const started = await invoke<string[]>('restart_all_mcp_servers');
toast({
title: 'Sucesso',
description: `${started.length} servidor(es) iniciado(s)`,
});
loadStatus();
} catch (error) {
console.error('Failed to restart all servers:', error);
toast({
title: 'Erro',
description: 'Falha ao reiniciar servidores',
variant: 'destructive',
});
}
};
const getStatusBadgeVariant = (status: string) => {
switch (status) {
case 'running':
return 'default';
case 'stopped':
return 'secondary';
case 'error':
return 'destructive';
default:
return 'secondary';
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
);
}
return (
<div className="h-full flex flex-col gap-4 p-4 overflow-hidden">
<div className="flex items-center justify-between flex-shrink-0">
<div>
<h2 className="text-xl font-semibold">MCP Servers</h2>
<p className="text-sm text-muted-foreground">
Gerencie servidores Model Context Protocol
</p>
{configPath && (
<p className="text-xs text-muted-foreground mt-1">
Config: <code className="text-xs bg-muted px-1 py-0.5 rounded">{configPath}</code>
</p>
)}
</div>
<Button onClick={restartAll} size="sm" variant="outline">
<RefreshCw className="w-4 h-4 mr-2" />
Reiniciar Todos
</Button>
</div>
{/* Server Status List */}
<div className="space-y-2 flex-shrink-0 overflow-y-auto">
{Object.keys(config.mcpServers).length === 0 ? (
<Card>
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground text-center">
Nenhum servidor MCP configurado. Adicione servidores no JSON abaixo.
</p>
</CardContent>
</Card>
) : (
Object.keys(config.mcpServers).map((name) => {
const status = statuses.find(s => s.name === name);
const isRunning = status?.status === 'running';
return (
<Card key={name}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CardTitle className="text-base">{name}</CardTitle>
{status && (
<Badge variant={getStatusBadgeVariant(status.status)}>
{status.status}
</Badge>
)}
{status?.pid && (
<span className="text-xs text-muted-foreground">
PID: {status.pid}
</span>
)}
</div>
<div className="flex gap-2">
{isRunning ? (
<>
<Button
size="sm"
variant="outline"
onClick={() => stopServer(name)}
>
<Square className="w-3 h-3 mr-1" />
Parar
</Button>
<Button
size="sm"
variant="outline"
onClick={() => restartServer(name)}
>
<RefreshCw className="w-3 h-3 mr-1" />
Reiniciar
</Button>
</>
) : (
<Button
size="sm"
variant="outline"
onClick={() => startServer(name)}
>
<Play className="w-3 h-3 mr-1" />
Iniciar
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-1 text-sm">
<div>
<span className="text-muted-foreground">Comando:</span>{' '}
<code className="text-xs bg-muted px-1 py-0.5 rounded">
{config.mcpServers[name].command} {config.mcpServers[name].args.join(' ')}
</code>
</div>
{config.mcpServers[name].env && (
<div>
<span className="text-muted-foreground">Env:</span>{' '}
<code className="text-xs bg-muted px-1 py-0.5 rounded">
{Object.keys(config.mcpServers[name].env || {}).join(', ')}
</code>
</div>
)}
</div>
</CardContent>
</Card>
);
})
)}
</div>
{/* JSON Editor */}
<Card className="flex flex-col flex-1 min-h-0 max-w-full overflow-hidden">
<CardHeader className="flex-shrink-0">
<CardTitle className="text-base">Configuração JSON</CardTitle>
<CardDescription className="truncate">
Edite a configuração MCP no formato JSON padrão
</CardDescription>
<CardAction>
<Button onClick={saveConfig} disabled={isSaving} size="sm">
{isSaving ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Save className="w-4 h-4 mr-2" />
)}
Salvar
</Button>
</CardAction>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 max-w-full overflow-hidden">
<McpJsonEditor
value={configJson}
onChange={setConfigJson}
placeholder='{\n "mcpServers": {\n "filesystem": {\n "command": "npx",\n "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"]\n }\n }\n}'
className="flex-1 max-w-full h-full"
/>
</CardContent>
</Card>
</div>
);
}
+184
View File
@@ -0,0 +1,184 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useTheme } from 'next-themes';
import { Button } from '@/components/ui/button';
import { AlertCircle, CheckCircle2, Wand2 } from 'lucide-react';
import { cn } from '@/lib/utils';
interface McpJsonEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
}
export function McpJsonEditor({ value, onChange, placeholder, className }: McpJsonEditorProps) {
const { theme, resolvedTheme } = useTheme();
const [isValid, setIsValid] = useState(true);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isFormatted, setIsFormatted] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const highlightContainerRef = useRef<HTMLDivElement>(null);
// Determine theme
const isDark = resolvedTheme === 'dark' || theme === 'dark';
const codeStyle = isDark ? vscDarkPlus : vs;
// Validate JSON and update state
const validateJson = (jsonString: string) => {
if (!jsonString.trim()) {
setIsValid(true);
setErrorMessage(null);
return;
}
try {
JSON.parse(jsonString);
setIsValid(true);
setErrorMessage(null);
} catch (e) {
setIsValid(false);
const error = e instanceof Error ? e.message : 'JSON inválido';
setErrorMessage(error);
}
};
// Format JSON
const formatJson = () => {
if (!value.trim()) return;
try {
const parsed = JSON.parse(value);
const formatted = JSON.stringify(parsed, null, 2);
onChange(formatted);
setIsFormatted(true);
setTimeout(() => setIsFormatted(false), 2000);
} catch (e) {
// If invalid, show error
validateJson(value);
}
};
// Handle input changes
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
onChange(newValue);
validateJson(newValue);
setIsFormatted(false);
};
// Sync scroll between textarea and highlight
const handleScroll = () => {
if (textareaRef.current && highlightContainerRef.current) {
const scrollTop = textareaRef.current.scrollTop;
const scrollLeft = textareaRef.current.scrollLeft;
const pre = highlightContainerRef.current.querySelector('pre');
if (pre) {
pre.scrollTop = scrollTop;
pre.scrollLeft = scrollLeft;
}
}
};
// Validate on mount and when value changes externally
useEffect(() => {
validateJson(value);
}, [value]);
// Count lines for display
const lineCount = value.split('\n').length;
return (
<div className={cn('relative flex flex-col h-full min-h-0', className)}>
{/* Toolbar */}
<div className="flex items-center justify-between mb-2 flex-shrink-0">
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={formatJson}
disabled={!value.trim()}
className="h-7 text-xs"
>
<Wand2 className="w-3 h-3 mr-1" />
Formatar
</Button>
{isFormatted && (
<div className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
<CheckCircle2 className="w-3 h-3" />
Formatado
</div>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{lineCount > 0 && <span>{lineCount} linha{lineCount !== 1 ? 's' : ''}</span>}
{!isValid && errorMessage && (
<div className="flex items-center gap-1 text-destructive">
<AlertCircle className="w-3 h-3" />
<span className="max-w-[200px] truncate">{errorMessage}</span>
</div>
)}
{isValid && value.trim() && (
<div className="flex items-center gap-1 text-green-600 dark:text-green-400">
<CheckCircle2 className="w-3 h-3" />
Válido
</div>
)}
</div>
</div>
{/* Editor Container */}
<div className="relative flex-1 min-h-[300px] border rounded-md overflow-hidden bg-background">
{/* Syntax Highlighted Background */}
<div ref={highlightContainerRef} className="absolute inset-0 pointer-events-none overflow-hidden">
<SyntaxHighlighter
language="json"
style={codeStyle}
customStyle={{
margin: 0,
padding: '0.75rem',
background: 'transparent',
fontSize: '0.875rem',
lineHeight: '1.5rem',
overflow: 'hidden',
height: '100%',
}}
PreTag="pre"
>
{value || placeholder || '{}'}
</SyntaxHighlighter>
</div>
{/* Editable Textarea */}
<textarea
ref={textareaRef}
value={value}
onChange={handleChange}
onScroll={handleScroll}
placeholder={placeholder}
className={cn(
'relative w-full h-full min-h-[300px] p-3 font-mono text-sm leading-6',
'bg-transparent text-transparent caret-foreground',
'resize-none border-none outline-none',
'selection:bg-primary/20',
'[&::placeholder]:text-muted-foreground/50',
!isValid && 'ring-1 ring-destructive'
)}
spellCheck={false}
style={{
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
overflow: 'auto',
overflowY: 'auto',
overflowX: 'auto',
}}
/>
</div>
</div>
);
}
+127
View File
@@ -0,0 +1,127 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuCheckboxItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
import { Server, CheckCircle2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { McpServerInfo } from '@/hooks/use-mcp-servers';
interface McpServerSelectorProps {
servers: McpServerInfo[];
selectedServers: string[];
onSelectionChange: (servers: string[]) => void;
isLoading?: boolean;
className?: string;
}
export function McpServerSelector({
servers,
selectedServers,
onSelectionChange,
isLoading = false,
className,
}: McpServerSelectorProps) {
const [open, setOpen] = useState(false);
// Filter only running servers
const runningServers = servers.filter(s => s.status === 'running');
const handleToggle = (serverName: string) => {
if (selectedServers.includes(serverName)) {
onSelectionChange(selectedServers.filter(s => s !== serverName));
} else {
onSelectionChange([...selectedServers, serverName]);
}
};
const handleSelectAll = () => {
if (selectedServers.length === runningServers.length) {
onSelectionChange([]);
} else {
onSelectionChange(runningServers.map(s => s.name));
}
};
const getButtonText = () => {
if (selectedServers.length === 0) {
return 'Servidores MCP';
}
if (selectedServers.length === 1) {
return selectedServers[0];
}
return `${selectedServers.length} servidores`;
};
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn('h-8 gap-2', className)}
disabled={isLoading}
>
<Server className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{getButtonText()}</span>
{selectedServers.length > 0 && (
<Badge variant="secondary" className="ml-1 h-5 px-1.5 text-xs">
{selectedServers.length}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="flex items-center justify-between">
<span>Servidores MCP</span>
{selectedServers.length > 0 && (
<span className="text-xs font-normal text-muted-foreground">
{selectedServers.length} selecionado{selectedServers.length !== 1 ? 's' : ''}
</span>
)}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={selectedServers.length === runningServers.length && runningServers.length > 0}
onCheckedChange={handleSelectAll}
className="font-medium"
>
Selecionar Todos
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
{runningServers.map((server) => (
<DropdownMenuCheckboxItem
key={server.name}
checked={selectedServers.includes(server.name)}
onCheckedChange={() => handleToggle(server.name)}
className="flex items-center justify-between"
>
<div className="flex items-center gap-2">
<CheckCircle2 className="h-3 w-3 text-green-600 dark:text-green-400" />
<span>{server.name}</span>
</div>
{server.toolCount !== undefined && server.toolCount > 0 && (
<Badge variant="outline" className="ml-2 h-5 px-1.5 text-xs">
{server.toolCount}
</Badge>
)}
</DropdownMenuCheckboxItem>
))}
{runningServers.length === 0 && (
<div className="px-2 py-1.5 text-sm text-muted-foreground">
Nenhum servidor rodando
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
+2
View File
@@ -79,3 +79,5 @@ export function MessageActions({ content, role }: MessageActionsProps) {
);
}
+152
View File
@@ -0,0 +1,152 @@
import { Search, BookOpen, CheckCircle2, AlertCircle, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { ScrapedContent } from '@/services/webSearch';
import { SearchStatus } from '@/hooks/use-web-search';
import { useState } from 'react';
import { ExternalLink } from 'lucide-react';
interface SearchProgressProps {
status: SearchStatus;
query: string;
sources: ScrapedContent[];
error: string | null;
isExpanded?: boolean;
onToggleExpand?: () => void;
}
export function SearchProgress({
status,
query,
sources,
error,
isExpanded: controlledExpanded,
onToggleExpand,
}: SearchProgressProps) {
const [internalExpanded, setInternalExpanded] = useState(false);
const isExpanded = controlledExpanded !== undefined ? controlledExpanded : internalExpanded;
const toggleExpanded = onToggleExpand || (() => setInternalExpanded(!internalExpanded));
if (status === 'idle') return null;
const getStatusIcon = () => {
switch (status) {
case 'searching':
return <Search className="w-3 h-3 text-muted-foreground" />;
case 'scraping':
return <BookOpen className="w-3 h-3 text-muted-foreground" />;
case 'completed':
return <CheckCircle2 className="w-3 h-3 text-muted-foreground" />;
case 'error':
return <AlertCircle className="w-3 h-3 text-destructive" />;
default:
return null;
}
};
const getStatusText = () => {
switch (status) {
case 'searching':
return `Pesquisando: "${query}"`;
case 'scraping':
return `Lendo ${sources.length || '...'} fonte${sources.length !== 1 ? 's' : ''}`;
case 'completed':
return `✓ ${sources.length} fonte${sources.length !== 1 ? 's' : ''} consultada${sources.length !== 1 ? 's' : ''}`;
case 'error':
return `Erro: ${error || 'Falha ao buscar'}`;
default:
return '';
}
};
const getFaviconUrl = (url: string): string => {
try {
const domain = new URL(url).hostname;
return `https://www.google.com/s2/favicons?domain=${domain}&sz=16`;
} catch {
return '';
}
};
const isActive = status === 'searching' || status === 'scraping';
const showExpandButton = status === 'completed' && sources.length > 0;
return (
<Card className={cn(
"mx-4 mb-2 border border-border/50 bg-muted/30 transition-all",
status === 'completed' && "opacity-60 hover:opacity-100",
status === 'error' && "border-destructive/30 bg-destructive/5"
)}>
<CardContent className="p-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="flex-shrink-0">
{isActive ? (
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
) : (
<div className="w-3 h-3">
{getStatusIcon()}
</div>
)}
</div>
<span className="text-xs text-muted-foreground truncate">
{getStatusText()}
</span>
</div>
{showExpandButton && (
<Button
variant="ghost"
size="sm"
onClick={toggleExpanded}
className="h-7 w-7 p-0 flex-shrink-0"
>
{isExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</Button>
)}
</div>
{/* Lista de fontes expandida */}
{isExpanded && sources.length > 0 && (
<div className="mt-3 pt-3 border-t space-y-2">
{sources.map((source, idx) => (
<div
key={idx}
className="flex items-start gap-2 p-2 rounded-md bg-background/50 hover:bg-background/80 transition-colors"
>
<img
src={getFaviconUrl(source.url)}
alt=""
className="w-4 h-4 rounded mt-0.5 flex-shrink-0"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
<div className="flex-1 min-w-0">
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-medium text-primary hover:underline line-clamp-1 flex items-center gap-1"
>
{source.title || source.url}
<ExternalLink className="w-3 h-3 flex-shrink-0" />
</a>
<p className="text-xs text-muted-foreground truncate mt-0.5">
{source.url}
</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
+89
View File
@@ -0,0 +1,89 @@
import { SessionSummary } from "@/hooks/use-chat-storage";
import { Button } from "@/components/ui/button";
import { Trash2, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
interface SidebarListProps {
sessions: SessionSummary[];
currentSessionId: string | null;
onSelectSession: (id: string) => void;
onDeleteSession: (id: string) => void;
onNewChat: () => void;
}
function formatDate(dateString: string) {
const date = new Date(dateString);
return new Intl.DateTimeFormat('pt-BR', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
}).format(date);
}
export function SidebarList({
sessions,
currentSessionId,
onSelectSession,
onDeleteSession,
onNewChat
}: SidebarListProps) {
return (
<div className="flex flex-col h-full border-r bg-muted/10">
<div className="p-4 border-b">
<Button onClick={onNewChat} className="w-full justify-start gap-2" variant="outline">
<Plus className="w-4 h-4" />
Nova Conversa
</Button>
</div>
<div className="flex-1 overflow-y-auto">
<div className="p-2 space-y-2">
{sessions.length === 0 && (
<div className="text-center text-muted-foreground text-sm py-8">
Nenhuma conversa salva
</div>
)}
{sessions.map((session) => (
<div
key={session.id}
className={cn(
"group flex items-center justify-between p-2 rounded-lg cursor-pointer hover:bg-accent transition-colors",
currentSessionId === session.id && "bg-accent"
)}
onClick={() => onSelectSession(session.id)}
>
<div className="flex items-start gap-3 overflow-hidden flex-1">
<span className="text-lg shrink-0 mt-0.5" role="img" aria-label="emoji">
{session.emoji || '💬'}
</span>
<div className="flex flex-col overflow-hidden flex-1">
<span className="text-sm font-medium truncate" title={session.title}>
{session.title}
</span>
<span className="text-xs text-muted-foreground truncate">
{formatDate(session.updated_at)}
</span>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
onClick={(e) => {
e.stopPropagation();
onDeleteSession(session.id);
}}
>
<Trash2 className="w-3 h-3 text-destructive" />
</Button>
</div>
))}
</div>
</div>
</div>
);
}
+2
View File
@@ -103,3 +103,5 @@ export function SystemPanel() {
);
}
+105
View File
@@ -0,0 +1,105 @@
import { Search, BookOpen, Brain, Loader2, CheckCircle2 } from 'lucide-react';
import { cn } from '@/lib/utils';
export type ThinkingStep =
| 'analyzing'
| 'searching'
| 'reading'
| 'formulating'
| 'complete';
interface ThinkingIndicatorProps {
currentStep: ThinkingStep;
searchQuery?: string;
sourcesRead?: number;
totalSources?: number;
onComplete?: () => void;
}
export function ThinkingIndicator({
currentStep,
searchQuery,
sourcesRead = 0,
totalSources = 0,
}: ThinkingIndicatorProps) {
const steps: Array<{
key: ThinkingStep;
label: string;
icon: React.ReactNode;
}> = [
{
key: 'analyzing',
label: 'Analisando intenção...',
icon: <Brain className="w-3 h-3" />,
},
{
key: 'searching',
label: searchQuery ? `Pesquisando: "${searchQuery}"` : 'Pesquisando na web...',
icon: <Search className="w-3 h-3" />,
},
{
key: 'reading',
label: totalSources > 0
? `Lendo ${sourcesRead}/${totalSources} fontes...`
: 'Lendo conteúdo...',
icon: <BookOpen className="w-3 h-3" />,
},
{
key: 'formulating',
label: 'Formulando resposta...',
icon: <Brain className="w-3 h-3" />,
},
];
const currentStepIndex = steps.findIndex(s => s.key === currentStep);
const isComplete = currentStep === 'complete';
if (isComplete) {
return null; // Não mostrar quando completo
}
return (
<div className="px-6 py-3 border-b border-muted/50 bg-muted/20">
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{steps.slice(0, currentStepIndex + 1).map((step, idx) => {
const isActive = idx === currentStepIndex;
const isPast = idx < currentStepIndex;
return (
<div key={step.key} className="flex items-center gap-2">
<div
className={cn(
"flex items-center justify-center w-5 h-5 rounded-full transition-colors",
isPast && "bg-green-500/20 text-green-600 dark:text-green-400",
isActive && "bg-blue-500/20 text-blue-600 dark:text-blue-400 animate-pulse",
!isActive && !isPast && "bg-muted text-muted-foreground"
)}
>
{isPast ? (
<CheckCircle2 className="w-3 h-3" />
) : isActive ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
step.icon
)}
</div>
<span
className={cn(
"transition-colors",
isActive && "text-foreground font-medium",
isPast && "text-muted-foreground line-through opacity-60"
)}
>
{step.label}
</span>
{idx < steps.length - 1 && (
<div className="w-8 h-px bg-muted mx-1" />
)}
</div>
);
})}
</div>
</div>
);
}
+164
View File
@@ -0,0 +1,164 @@
import { Search, BookOpen, CheckCircle2, AlertCircle, ExternalLink, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { ScrapedContent } from '@/services/webSearch';
import { SearchStatus } from '@/hooks/use-web-search';
import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
interface WebSearchStatusProps {
status: SearchStatus;
query: string;
sources: ScrapedContent[];
error: string | null;
onViewSources?: () => void;
}
export function WebSearchStatus({
status,
query,
sources,
error,
}: WebSearchStatusProps) {
const [isSourcesOpen, setIsSourcesOpen] = useState(false);
if (status === 'idle') return null;
const getStatusIcon = () => {
switch (status) {
case 'searching':
return <Search className="w-4 h-4 animate-pulse" />;
case 'scraping':
return <BookOpen className="w-4 h-4 animate-pulse" />;
case 'completed':
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
case 'error':
return <AlertCircle className="w-4 h-4 text-red-500" />;
default:
return null;
}
};
const getStatusText = () => {
switch (status) {
case 'searching':
return `🔍 Pesquisando por: "${query}"...`;
case 'scraping':
return `📖 Lendo ${sources.length || '...'} fonte${sources.length !== 1 ? 's' : ''}...`;
case 'completed':
return `✅ ${sources.length} fonte${sources.length !== 1 ? 's' : ''} encontrada${sources.length !== 1 ? 's' : ''}`;
case 'error':
return `❌ Erro: ${error || 'Falha ao buscar'}`;
default:
return '';
}
};
const getFaviconUrl = (url: string): string => {
try {
const domain = new URL(url).hostname;
return `https://www.google.com/s2/favicons?domain=${domain}&sz=16`;
} catch {
return '';
}
};
return (
<>
<Card className={cn(
"mx-4 mb-2 border-l-4",
status === 'completed' && "border-l-green-500",
status === 'error' && "border-l-red-500",
(status === 'searching' || status === 'scraping') && "border-l-blue-500"
)}>
<CardContent className="p-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="flex-shrink-0">
{status === 'searching' || status === 'scraping' ? (
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
) : (
getStatusIcon()
)}
</div>
<span className="text-sm font-medium truncate">
{getStatusText()}
</span>
</div>
{status === 'completed' && sources.length > 0 && (
<div className="flex items-center gap-2 flex-shrink-0">
{/* Favicons das fontes */}
<div className="flex items-center gap-1">
{sources.slice(0, 3).map((source, idx) => (
<img
key={idx}
src={getFaviconUrl(source.url)}
alt=""
className="w-4 h-4 rounded"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
))}
{sources.length > 3 && (
<span className="text-xs text-muted-foreground">+{sources.length - 3}</span>
)}
</div>
<Dialog open={isSourcesOpen} onOpenChange={setIsSourcesOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 text-xs">
Ver Fontes
</Button>
</DialogTrigger>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>Fontes Encontradas</DialogTitle>
<DialogDescription>
{sources.length} fonte{sources.length !== 1 ? 's' : ''} analisada{sources.length !== 1 ? 's' : ''} para: "{query}"
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-4 pr-2">
{sources.map((source, idx) => (
<Card key={idx} className="border">
<CardContent className="p-4">
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-sm mb-1 line-clamp-2">
{source.title}
</h4>
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-primary flex items-center gap-1 truncate"
>
{source.url}
<ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
<div className="prose dark:prose-invert prose-sm max-w-none max-h-[300px] overflow-y-auto">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{source.markdown}
</ReactMarkdown>
</div>
</CardContent>
</Card>
))}
</div>
</DialogContent>
</Dialog>
</div>
)}
</div>
</CardContent>
</Card>
</>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { useState, KeyboardEvent } from 'react';
import { X } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
interface DomainTagsInputProps {
domains: string[];
onAdd: (domain: string) => void;
onRemove: (domain: string) => void;
placeholder?: string;
}
export function DomainTagsInput({
domains,
onAdd,
onRemove,
placeholder = 'Digite um domínio e pressione Enter...',
}: DomainTagsInputProps) {
const [inputValue, setInputValue] = useState('');
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && inputValue.trim()) {
e.preventDefault();
onAdd(inputValue.trim());
setInputValue('');
}
};
const normalizeDomain = (domain: string): string => {
return domain
.toLowerCase()
.trim()
.replace(/^https?:\/\//, '')
.replace(/\/$/, '')
.split('/')[0]; // Remove path
};
return (
<div className="space-y-2">
<Label>Domínios Bloqueados</Label>
<div className="flex flex-wrap gap-2 p-3 border rounded-md min-h-[50px] bg-background">
{domains.map((domain) => (
<Badge
key={domain}
variant="secondary"
className="flex items-center gap-1 pr-1"
>
{domain}
<button
type="button"
onClick={() => onRemove(domain)}
className="ml-1 rounded-full hover:bg-destructive/20 p-0.5 transition-colors"
>
<X className="w-3 h-3" />
</button>
</Badge>
))}
<Input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={domains.length === 0 ? placeholder : ''}
className="flex-1 min-w-[200px] border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-auto p-0"
/>
</div>
<p className="text-xs text-muted-foreground">
Digite um domínio (ex: youtube.com) e pressione Enter para adicionar à lista negra.
</p>
</div>
);
}
+2
View File
@@ -10,3 +10,5 @@ export function ThemeProvider({
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
+2
View File
@@ -197,3 +197,5 @@ export {
DropdownMenuRadioGroup,
}
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
+27
View File
@@ -0,0 +1,27 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
+2
View File
@@ -25,3 +25,5 @@ Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }
+2
View File
@@ -157,3 +157,5 @@ export {
SelectScrollDownButton,
}
+27
View File
@@ -0,0 +1,27 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }
+30
View File
@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
+2
View File
@@ -21,3 +21,5 @@ Textarea.displayName = "Textarea"
export { Textarea }
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }