diff --git a/app/chat/page.tsx b/app/chat/page.tsx index 854eb26..7c36bdd 100644 --- a/app/chat/page.tsx +++ b/app/chat/page.tsx @@ -9,6 +9,7 @@ import { MessageSquare, Settings, Server, Moon, Sun, PanelLeftClose, PanelLeftOp import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { ChatInput } from "@/components/chat/chat-input"; import { ChatMessage } from "@/components/chat/chat-message"; +import { ReasoningChain } from "@/components/chat/reasoning-chain"; import { SidebarList } from "@/components/chat/sidebar-list"; import { useChat } from "@/hooks/use-chat"; @@ -122,8 +123,7 @@ export default function ChatPage() { // Initialize with default format prompt const [systemPrompt, setSystemPrompt] = useState(defaultFormatPrompt || "Você é um assistente útil e prestativo."); const [isChatsSidebarCollapsed, setIsChatsSidebarCollapsed] = useState(true); // Iniciar colapsada - const [thinkingStep, setThinkingStep] = useState(null); - const [processSteps, setProcessSteps] = useState([]); + // Removido: thinkingStep e processSteps - agora usando mensagens thinking agrupadas const [error, setError] = useState(null); const chatsSidebarRef = useRef(null); @@ -266,10 +266,7 @@ export default function ChatPage() { // Adicionar mensagem de processo: Pré-processamento addThinkingMessage('preprocessing', 'Pré-processamento', 'running'); - // Resetar e inicializar steps do processo (para compatibilidade com componentes antigos) - setProcessSteps([ - { id: 'preprocessing', label: 'Pré-processamento', status: 'running', timestamp: Date.now() }, - ]); + // Removido: setProcessSteps - agora usando mensagens thinking agrupadas // Limpar erros anteriores setError(null); @@ -305,11 +302,7 @@ export default function ChatPage() { duration: Date.now() - preprocessingStart, }); - setProcessSteps(prev => prev.map(s => - s.id === 'preprocessing' - ? { ...s, status: 'error' as const, error: errorMessage } - : s - )); + // Removido: setProcessSteps - agora usando mensagens thinking agrupadas // Definir erro global setError(error); @@ -369,14 +362,12 @@ export default function ChatPage() { // Passo 1: Decomposição chatLog.info('Step 1: Decomposition'); - setThinkingStep('planning'); const searchPlan = await deepResearch.decompose(content, selectedModel); chatLog.info(`Search Plan: ${JSON.stringify(searchPlan)}`); if (searchPlan && searchPlan.length > 0) { // Passo 2: Executar buscas com fallback progressivo chatLog.info(`\nStep 2: Executing ${searchPlan.length} searches with progressive fallback`); - setThinkingStep('searching'); // Atualizar progresso: decomposição concluída updateThinkingMessage('web-research', { @@ -567,13 +558,11 @@ export default function ChatPage() { } else { // Fallback para busca simples chatLog.info('Decomposition returned empty, falling back to simple search'); - setThinkingStep('searching'); const simpleQuery = await generateQuery(content, selectedModel); chatLog.info(`Simple query generated: "${simpleQuery}"`); if (simpleQuery && simpleQuery !== 'NO_SEARCH') { const results = await webSearch.smartSearchRag(simpleQuery, settings.webSearch.maxResults); chatLog.info(`Simple search results: ${results.length}`); - setThinkingStep('reading'); if (results.length > 0) { deepResearch.addToKnowledgeBase(simpleQuery, results); knowledgeBaseContext = deepResearch.getCuratedContext(); @@ -617,16 +606,7 @@ export default function ChatPage() { }; // ========== LÓGICA DE DEEP RESEARCH (Knowledge Base Aggregation) ========== - // Iniciar indicador de pensamento - setThinkingStep('analyzing'); - - // Adicionar step de pesquisa web se necessário - if (webSearch.isEnabled && preprocessed.shouldSearch) { - setProcessSteps(prev => [ - ...prev, - { id: 'web-research', label: 'Pesquisa Web', status: 'running', timestamp: Date.now() }, - ]); - } + // Removido: setThinkingStep e setProcessSteps - agora usando mensagens thinking agrupadas const researchStart = Date.now(); // Executar pesquisa web @@ -635,24 +615,9 @@ export default function ChatPage() { preprocessed ); - // Atualizar step de pesquisa web - if (webSearch.isEnabled && preprocessed.shouldSearch) { - setProcessSteps(prev => prev.map(s => - s.id === 'web-research' - ? { ...s, status: 'completed' as const, duration: Date.now() - researchStart } - : s - )); - } - // Passo 5: Formular resposta chatLog.info('\nStep 5: Formulating Response'); - setThinkingStep('formulating'); - - // Adicionar step de geração de resposta - setProcessSteps(prev => [ - ...prev, - { id: 'response-generation', label: 'Geração de Resposta', status: 'running', timestamp: Date.now() }, - ]); + // Removido: setThinkingStep e setProcessSteps - agora usando mensagens thinking agrupadas // ========== MONTAR SYSTEM PROMPT COM CONTEXTO TEMPORAL E VALIDAÇÃO ========== // Usar timezone do sistema do usuário (não fixo) @@ -793,12 +758,7 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente duration: Date.now() - responseStart, }); - // Atualizar step de geração de resposta quando streaming completar (para compatibilidade) - setProcessSteps(prev => prev.map(s => - s.id === 'response-generation' - ? { ...s, status: 'completed' as const, duration: Date.now() - responseStart } - : s - )); + // Removido: setProcessSteps - agora usando mensagens thinking agrupadas chatLog.info('✅ Response generation complete'); chatLog.info('========== QUERY COMPLETE ==========\n'); @@ -831,8 +791,7 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente }); } - // Marcar como completo quando começar a streamar - setTimeout(() => setThinkingStep('complete'), 100); + // Removido: setThinkingStep - agora usando mensagens thinking agrupadas }; const handleSelectSession = async (id: string) => { @@ -1102,24 +1061,98 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente ) : (
- {messages.map((msg, i) => { - const isLastMessage = i === messages.length - (isLoading ? 1 : 0); // Ajuste simples - // Streaming logica mantida simples - const isAssistantStreaming = i === messages.length - 1 && msg.role === 'assistant' && isLoading; + {(() => { + // Agrupar mensagens thinking consecutivas + const groupedMessages: Array<{ + type: 'thinking-group' | 'regular'; + messages?: Message[]; + thinkingSteps?: ThinkingMessageMetadata[]; + searchQueries?: string[]; + index?: number; + message?: Message; + isStreaming?: boolean; + }> = []; - return ( -
- -
- ); - })} - - {isLoading && ( -
-
- Pensando... -
- )} + let currentThinkingGroup: ThinkingMessageMetadata[] = []; + let currentSearchQueries: string[] = []; + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + const isThinkingMessage = msg.metadata && + typeof msg.metadata === 'object' && + 'type' in msg.metadata && + msg.metadata.type === 'thinking'; + + if (isThinkingMessage) { + const thinkingMeta = msg.metadata as ThinkingMessageMetadata; + currentThinkingGroup.push(thinkingMeta); + + // Extrair queries de busca dos detalhes + if (thinkingMeta.details && thinkingMeta.stepType === 'web-research') { + const queryMatch = thinkingMeta.details.match(/Buscando consulta \d+\/\d+: "([^"]+)"/); + if (queryMatch && queryMatch[1]) { + if (!currentSearchQueries.includes(queryMatch[1])) { + currentSearchQueries.push(queryMatch[1]); + } + } + } + } else { + // Se havia um grupo thinking, adicionar antes desta mensagem + if (currentThinkingGroup.length > 0) { + groupedMessages.push({ + type: 'thinking-group', + thinkingSteps: [...currentThinkingGroup], + searchQueries: [...currentSearchQueries], + }); + currentThinkingGroup = []; + currentSearchQueries = []; + } + + // Adicionar mensagem regular + const isLastMessage = i === messages.length - (isLoading ? 1 : 0); + const isAssistantStreaming = i === messages.length - 1 && msg.role === 'assistant' && isLoading; + + groupedMessages.push({ + type: 'regular', + message: msg, + index: i, + isStreaming: isAssistantStreaming, + }); + } + } + + // Adicionar grupo thinking final se houver + if (currentThinkingGroup.length > 0) { + groupedMessages.push({ + type: 'thinking-group', + thinkingSteps: currentThinkingGroup, + searchQueries: currentSearchQueries, + }); + } + + return groupedMessages.map((group, idx) => { + if (group.type === 'thinking-group' && group.thinkingSteps) { + return ( +
+ +
+ ); + } else if (group.type === 'regular' && group.message) { + return ( +
+ +
+ ); + } + return null; + }); + })()}
)} diff --git a/app/globals.css b/app/globals.css index 616d8ff..fc84304 100644 --- a/app/globals.css +++ b/app/globals.css @@ -296,4 +296,62 @@ .sidebar-chat-list { scrollbar-width: thin; scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent; -} \ No newline at end of file +} + +/* Minimalista scrollbar para sources */ +.sources-scrollbar { + scrollbar-width: thin; + scrollbar-color: hsl(var(--muted-foreground) / 0.2) transparent; +} + +.sources-scrollbar::-webkit-scrollbar { + height: 4px; +} + +.sources-scrollbar::-webkit-scrollbar-track { + background: transparent; + border-radius: 2px; +} + +.sources-scrollbar::-webkit-scrollbar-thumb { + background: hsl(var(--muted-foreground) / 0.2); + border-radius: 2px; + transition: background 0.2s ease; +} + +.sources-scrollbar::-webkit-scrollbar-thumb:hover { + background: hsl(var(--muted-foreground) / 0.35); +} + +/* Ocultar scrollbar quando não há overflow */ +.sources-scrollbar:not(:hover)::-webkit-scrollbar-thumb { + background: transparent; +} + +/* Reasoning Chain animations */ +@keyframes reasoning-fade-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.reasoning-step { + animation: reasoning-fade-in 0.3s ease-out forwards; + opacity: 0; +} + +/* Staggered reveal para steps */ +.reasoning-step:nth-child(1) { animation-delay: 0ms; } +.reasoning-step:nth-child(2) { animation-delay: 100ms; } +.reasoning-step:nth-child(3) { animation-delay: 200ms; } +.reasoning-step:nth-child(4) { animation-delay: 300ms; } +.reasoning-step:nth-child(5) { animation-delay: 400ms; } +.reasoning-step:nth-child(6) { animation-delay: 500ms; } +.reasoning-step:nth-child(7) { animation-delay: 600ms; } +.reasoning-step:nth-child(8) { animation-delay: 700ms; } +.reasoning-step:nth-child(n+9) { animation-delay: 800ms; } \ No newline at end of file diff --git a/components/chat/message-step-thinking.tsx b/components/chat/message-step-thinking.tsx index 83bbd7f..47284ca 100644 --- a/components/chat/message-step-thinking.tsx +++ b/components/chat/message-step-thinking.tsx @@ -27,65 +27,27 @@ export function MessageStepThinking({ metadata }: MessageStepThinkingProps) { const [isExpanded, setIsExpanded] = useState(false); const [copiedIndex, setCopiedIndex] = useState(null); - const getStepConfig = () => { - switch (metadata.stepType) { - case 'preprocessing': - return { - icon: Brain, - textColor: "text-blue-600 dark:text-blue-400", - bgColor: "bg-blue-50 dark:bg-blue-900/20", - }; - case 'web-research': - return { - icon: Globe, - textColor: "text-green-600 dark:text-green-400", - bgColor: "bg-green-50 dark:bg-green-900/20", - }; - case 'sources-found': - return { - icon: Package, - textColor: "text-purple-600 dark:text-purple-400", - bgColor: "bg-purple-50 dark:bg-purple-900/20", - }; - case 'processing': - return { - icon: Brain, - textColor: "text-indigo-600 dark:text-indigo-400", - bgColor: "bg-indigo-50 dark:bg-indigo-900/20", - }; - case 'response-generation': - return { - icon: Loader2, - textColor: "text-orange-600 dark:text-orange-400", - bgColor: "bg-orange-50 dark:bg-orange-900/20", - }; - case 'fallback': - return { - icon: AlertCircle, - textColor: "text-yellow-600 dark:text-yellow-400", - bgColor: "bg-yellow-50 dark:bg-yellow-900/20", - }; - case 'error': - return { - icon: XCircle, - textColor: "text-red-600 dark:text-red-400", - bgColor: "bg-red-50 dark:bg-red-900/20", - }; - default: - return { - icon: Search, - textColor: "text-muted-foreground", - bgColor: "bg-muted/30", - }; - } - }; - const getStepIcon = () => { - const config = getStepConfig(); - const Icon = config.icon; const isSpinning = metadata.stepType === 'response-generation' && metadata.status === 'running'; - return ; + switch (metadata.stepType) { + case 'preprocessing': + return ; + case 'web-research': + return ; + case 'sources-found': + return ; + case 'processing': + return ; + case 'response-generation': + return ; + case 'fallback': + return ; + case 'error': + return ; + default: + return ; + } }; const copyToClipboard = async (url: string, index: number) => { @@ -98,17 +60,17 @@ export function MessageStepThinking({ metadata }: MessageStepThinkingProps) { } }; - const config = getStepConfig(); - return (
-
+
{getStepIcon()} - + {metadata.label}
@@ -135,7 +97,7 @@ export function MessageStepThinking({ metadata }: MessageStepThinkingProps) { {metadata.progress !== undefined && metadata.progress < 100 && (
diff --git a/components/chat/reasoning-chain.tsx b/components/chat/reasoning-chain.tsx new file mode 100644 index 0000000..bc59d64 --- /dev/null +++ b/components/chat/reasoning-chain.tsx @@ -0,0 +1,134 @@ +'use client'; + +import { + Loader2, + CheckCircle2, + Circle, + Search, + Globe, + Brain, + Package, + BookOpen +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { ThinkingMessageMetadata } from '@/hooks/use-chat'; +import { SourcesCompact } from './sources-compact'; + +interface ReasoningChainProps { + steps: ThinkingMessageMetadata[]; + searchQueries?: string[]; + className?: string; +} + +export function ReasoningChain({ steps, searchQueries = [], className }: ReasoningChainProps) { + const hasActiveStep = steps.some(step => step.status === 'running'); + const allSources = steps + .flatMap(step => step.sources || []) + .filter((source, index, self) => + index === self.findIndex(s => s.url === source.url) + ); + + const getStepIcon = (step: ThinkingMessageMetadata) => { + const isRunning = step.status === 'running'; + const isCompleted = step.status === 'completed'; + const isError = step.status === 'error'; + + if (isError) { + return null; // Não mostrar ícone para erros, apenas texto + } + + if (isCompleted) { + return ; + } + + if (isRunning) { + return ; + } + + // Ícone baseado no tipo de step + switch (step.stepType) { + case 'preprocessing': + return ; + case 'web-research': + return ; + case 'sources-found': + return ; + case 'processing': + return ; + case 'response-generation': + return ; + default: + return ; + } + }; + + return ( +
+ {/* Header: "Working..." com spinner sutil */} +
+ + Working... +
+ + {/* Lista vertical de steps - texto simples */} +
+ {steps.map((step, index) => { + const isRunning = step.status === 'running'; + const isCompleted = step.status === 'completed'; + const isError = step.status === 'error'; + + return ( +
+ {getStepIcon(step)} + {step.label} + {step.details && ( + + {step.details} + + )} +
+ ); + })} +
+ + {/* Pills de busca - cápsulas sutis */} + {searchQueries.length > 0 && ( +
+ Searching: + {searchQueries.map((query, index) => ( + + {query} + + ))} +
+ )} + + {/* Fontes em revisão */} + {allSources.length > 0 && ( +
+
+ + Reviewing sources - {allSources.length} + +
+ +
+ )} +
+ ); +} + diff --git a/components/chat/sources-compact.tsx b/components/chat/sources-compact.tsx new file mode 100644 index 0000000..bd3f3b1 --- /dev/null +++ b/components/chat/sources-compact.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { ExternalLink } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface Source { + url: string; + title?: string; +} + +interface SourcesCompactProps { + sources: Source[]; + className?: string; +} + +export function SourcesCompact({ sources, className }: SourcesCompactProps) { + 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 getDomain = (url: string): string => { + try { + return new URL(url).hostname.replace(/^www\./, ''); + } catch { + return url.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0]; + } + }; + + if (!sources || sources.length === 0) { + return null; + } + + return ( +
+ {sources.map((source, idx) => { + const faviconUrl = getFaviconUrl(source.url); + const domain = getDomain(source.url); + + return ( + + {faviconUrl ? ( + { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> + ) : ( + + )} +
+
+ {source.title || domain} +
+
+ {domain} +
+
+
+ ); + })} +
+ ); +} + diff --git a/components/chat/sources-display.tsx b/components/chat/sources-display.tsx index 9f3936b..de31244 100644 --- a/components/chat/sources-display.tsx +++ b/components/chat/sources-display.tsx @@ -27,90 +27,89 @@ export function SourcesDisplay({ sources, className }: SourcesDisplayProps) { } }; - // Memoizar URLs de favicon para evitar recálculos - const faviconUrls = useMemo(() => { - return sources.map(source => { - try { - const domain = new URL(source.url).hostname; - return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`; - } catch { - return null; - } - }); - }, [sources]); + 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 getDomain = (url: string): string => { + try { + return new URL(url).hostname.replace(/^www\./, ''); + } catch { + return url.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0]; + } + }; return ( -
+
- -

+ +

Fontes consultadas ({sources.length}):

-
+
{sources.map((source, idx) => { - const faviconUrl = faviconUrls[idx]; + const faviconUrl = getFaviconUrl(source.url); + const domain = getDomain(source.url); const isCopied = copiedIndex === idx; return ( -
{ + // Permitir que o botão de copiar funcione sem navegar + if ((e.target as HTMLElement).closest('button')) { + e.preventDefault(); + } + }} > - {/* Favicon */} {faviconUrl ? ( { (e.target as HTMLImageElement).style.display = 'none'; }} /> ) : ( - + )} - - {/* Title and URL */} -
-
- {source.title || `Fonte ${idx + 1}`} +
+
+ {source.title || domain}
-
- {source.url.replace(/^https?:\/\//, '').replace(/^www\./, '')} +
+ {domain}
- - {/* Actions */} -
- {/* Copy Button */} - - - {/* External Link Button */} - - - -
-
+ + ); })}