- Added `emoji-regex` and `remove-accents` packages to improve text processing capabilities. - Implemented automatic Ollama startup in the chat page for better user experience. - Enhanced setup page to include GPU selection based on hardware specifications. - Updated chat input to support real-time validation and improved user feedback. - Refactored message handling in chat to incorporate new processing features.
1192 lines
52 KiB
TypeScript
1192 lines
52 KiB
TypeScript
'use client';
|
|
|
|
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Label } from "@/components/ui/label";
|
|
import { MessageSquare, Settings, Server, Moon, Sun, PanelLeftClose, PanelLeftOpen, Loader2, ChevronDown, Plus, ScrollText, Copy, Check } from "lucide-react";
|
|
import { ChatInput } from "@/components/chat/chat-input";
|
|
import { ChatMessage } from "@/components/chat/chat-message";
|
|
|
|
import { SidebarList } from "@/components/chat/sidebar-list";
|
|
import { useChat } from "@/hooks/use-chat";
|
|
import { useLocalModels } from "@/hooks/use-local-models";
|
|
import { useChatStorage } from "@/hooks/use-chat-storage";
|
|
import { useAutoLabelingModel } from "@/hooks/use-auto-labeling-model";
|
|
import { useWebSearch } from "@/hooks/use-web-search";
|
|
// SearchProgress removido - informações agora nas mensagens de processo
|
|
// ThinkingIndicator removido - usando mensagens de processo integradas
|
|
// Componentes de processamento removidos - agora usando mensagens de processo integradas na timeline
|
|
import { useQueryGenerator } from "@/hooks/use-query-generator";
|
|
import { useDeepResearch } from "@/hooks/use-deep-research";
|
|
import { DEEP_RESEARCH_PROMPTS } from "@/data/prompts/deep-research";
|
|
import { useSettingsStore } from "@/store/settings-store";
|
|
import type { ScrapedContent } from "@/services/webSearch";
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
|
import { useTheme } from "next-themes";
|
|
import { useRouter } from "next/navigation";
|
|
import { ImperativePanelHandle } from "react-resizable-panels";
|
|
import { ModelDownloadDialog } from "@/components/chat/model-download-dialog";
|
|
import { sanitizeWebSources } from "@/lib/sanitize-web-content";
|
|
import { chatLog } from "@/lib/terminal-logger";
|
|
import { useQueryPreprocessor, type PreprocessedQuery } from "@/hooks/use-query-preprocessor";
|
|
import type { Message, ThinkingMessageMetadata, ThinkingStepType, ThinkingStepStatus } from "@/hooks/use-chat";
|
|
import { executeProgressiveSearch, type FallbackResult } from "@/lib/web-search-fallback";
|
|
// @ts-ignore
|
|
import defaultFormatPrompt from "@/data/prompts/default-format.md";
|
|
|
|
|
|
export default function ChatPage() {
|
|
const router = useRouter();
|
|
const { messages, setMessages, sendMessage, isLoading, stop, clearChat } = useChat();
|
|
const { models, refresh } = 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 deepResearch = useDeepResearch();
|
|
const settings = useSettingsStore();
|
|
const { preprocess } = useQueryPreprocessor();
|
|
|
|
// Funções auxiliares para gerenciar mensagens de processo
|
|
const addThinkingMessage = useCallback((
|
|
stepType: ThinkingStepType,
|
|
label: string,
|
|
status: ThinkingStepStatus = 'running',
|
|
details?: string,
|
|
progress?: number
|
|
): string => {
|
|
const messageId = `thinking-${stepType}-${Date.now()}`;
|
|
const thinkingMessage: Message = {
|
|
role: 'system',
|
|
content: '',
|
|
metadata: {
|
|
type: 'thinking',
|
|
stepType,
|
|
status,
|
|
label,
|
|
details,
|
|
progress,
|
|
timestamp: Date.now(),
|
|
} as ThinkingMessageMetadata,
|
|
};
|
|
|
|
setMessages(prev => [...prev, thinkingMessage]);
|
|
return messageId;
|
|
}, []);
|
|
|
|
const updateThinkingMessage = useCallback((
|
|
stepType: ThinkingStepType,
|
|
updates: Partial<ThinkingMessageMetadata>
|
|
) => {
|
|
setMessages(prev => prev.map(msg => {
|
|
const metadata = msg.metadata as ThinkingMessageMetadata | undefined;
|
|
if (metadata?.type === 'thinking' && metadata.stepType === stepType) {
|
|
return {
|
|
...msg,
|
|
metadata: {
|
|
...metadata,
|
|
...updates,
|
|
} as ThinkingMessageMetadata,
|
|
};
|
|
}
|
|
return msg;
|
|
}));
|
|
}, []);
|
|
|
|
const removeThinkingMessage = useCallback((stepType: ThinkingStepType) => {
|
|
setMessages(prev => prev.filter(msg => {
|
|
const metadata = msg.metadata as ThinkingMessageMetadata | undefined;
|
|
return !(metadata?.type === 'thinking' && metadata.stepType === stepType);
|
|
}));
|
|
}, []);
|
|
|
|
const [selectedModel, setSelectedModel] = useState("");
|
|
const [mounted, setMounted] = useState(false);
|
|
const initializedRef = useRef(false);
|
|
// Initialize with default format prompt
|
|
const [systemPrompt, setSystemPrompt] = useState(defaultFormatPrompt || "Você é um assistente útil e prestativo.");
|
|
const [isChatsSidebarCollapsed, setIsChatsSidebarCollapsed] = useState(false);
|
|
const [thinkingStep, setThinkingStep] = useState<ThinkingStep | null>(null);
|
|
const [processSteps, setProcessSteps] = useState<ProcessStep[]>([]);
|
|
const [error, setError] = useState<string | Error | null>(null);
|
|
|
|
const chatsSidebarRef = useRef<ImperativePanelHandle>(null);
|
|
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
const lastSavedMessagesRef = useRef<number>(0);
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
const [showContextDebug, setShowContextDebug] = useState(false);
|
|
const [showProcessDebug, setShowProcessDebug] = useState(false);
|
|
const [lastWebContext, setLastWebContext] = useState('');
|
|
const [lastContextSources, setLastContextSources] = useState<ScrapedContent[]>([]);
|
|
const [showDownloadDialog, setShowDownloadDialog] = useState(false);
|
|
const [logsCopied, setLogsCopied] = useState(false);
|
|
const [lastUserQuery, setLastUserQuery] = useState('');
|
|
|
|
// Auto-select first model
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (initializedRef.current) return;
|
|
const stored = settings.selectedModel;
|
|
const first = models.length > 0 ? models[0].name : "";
|
|
const next = stored || first || "";
|
|
if (next) {
|
|
setSelectedModel(next);
|
|
if (!stored) {
|
|
settings.setSelectedModel(next);
|
|
}
|
|
}
|
|
initializedRef.current = true;
|
|
}, [settings.selectedModel, models]);
|
|
|
|
// 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]);
|
|
|
|
|
|
// Função auxiliar para pré-processar query (memoizada com useCallback)
|
|
const preprocessQuery = useCallback(async (content: string): Promise<{
|
|
preprocessed: PreprocessedQuery;
|
|
finalContent: string;
|
|
}> => {
|
|
const preprocessingConfig = settings.queryPreprocessing || {
|
|
enabled: true,
|
|
minLength: 3,
|
|
maxLength: 2000,
|
|
autoSplitQuestions: true,
|
|
irrelevantPatterns: [],
|
|
};
|
|
|
|
let preprocessed: PreprocessedQuery;
|
|
if (preprocessingConfig.enabled) {
|
|
chatLog.info('\n========== PRE-PROCESSING ==========');
|
|
preprocessed = await preprocess(content);
|
|
chatLog.info(`Original: "${preprocessed.original}"`);
|
|
chatLog.info(`Normalized: "${preprocessed.normalized}"`);
|
|
chatLog.info(`Intent: ${preprocessed.intent}`);
|
|
chatLog.info(`Should Search: ${preprocessed.shouldSearch}`);
|
|
chatLog.info(`Questions Split: ${preprocessed.splitResult.splitCount}`);
|
|
|
|
// Se inválida, retornar erro
|
|
if (!preprocessed.validation.isValid) {
|
|
chatLog.error('Query validation failed:', preprocessed.validation.errors);
|
|
throw new Error(`Query validation failed: ${preprocessed.validation.errors.join(', ')}`);
|
|
}
|
|
|
|
// Se múltiplas perguntas detectadas, usar apenas a primeira (com aviso)
|
|
let finalContent = preprocessed.original;
|
|
if (preprocessed.questions.length > 1) {
|
|
chatLog.warn(`Multiple questions detected (${preprocessed.questions.length}). Processing first question only.`);
|
|
finalContent = preprocessed.questions[0];
|
|
}
|
|
|
|
return { preprocessed, finalContent };
|
|
} else {
|
|
// Se pré-processamento desabilitado, criar objeto básico
|
|
preprocessed = {
|
|
original: content,
|
|
normalized: content,
|
|
questions: [content],
|
|
intent: 'unknown' as const,
|
|
validation: { isValid: true, errors: [], warnings: [], normalizedLength: content.length },
|
|
shouldSearch: webSearch.isEnabled,
|
|
splitResult: { questions: [content], originalText: content, splitCount: 1 },
|
|
};
|
|
return { preprocessed, finalContent: content };
|
|
}
|
|
}, [settings.queryPreprocessing, webSearch.isEnabled]);
|
|
|
|
const handleSend = async (content: string) => {
|
|
if (!selectedModel) return;
|
|
|
|
// ========== ADICIONAR MENSAGENS IMEDIATAMENTE ==========
|
|
// Adicionar mensagem do usuário ao estado antes de qualquer processamento
|
|
// para feedback visual instantâneo
|
|
setMessages(prev => [...prev, { role: 'user', content }]);
|
|
|
|
// 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() },
|
|
]);
|
|
|
|
// Limpar erros anteriores
|
|
setError(null);
|
|
|
|
// ========== PRÉ-PROCESSAMENTO ==========
|
|
let preprocessed: PreprocessedQuery;
|
|
let finalContent: string;
|
|
const preprocessingStart = Date.now();
|
|
try {
|
|
const result = await preprocessQuery(content);
|
|
preprocessed = result.preprocessed;
|
|
finalContent = result.finalContent;
|
|
content = finalContent; // Atualizar content para uso posterior
|
|
|
|
// Atualizar mensagem de processo: Pré-processamento concluído
|
|
updateThinkingMessage('preprocessing', {
|
|
status: 'completed',
|
|
duration: Date.now() - preprocessingStart,
|
|
});
|
|
} catch (error) {
|
|
// Se validação falhar, remover mensagens adicionadas e mostrar erro
|
|
setMessages(prev => {
|
|
const newMessages = [...prev];
|
|
// Remover última mensagem de processo e última do usuário
|
|
return newMessages.slice(0, -2);
|
|
});
|
|
|
|
// Atualizar step com erro
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
updateThinkingMessage('preprocessing', {
|
|
status: 'error',
|
|
error: errorMessage,
|
|
duration: Date.now() - preprocessingStart,
|
|
});
|
|
|
|
setProcessSteps(prev => prev.map(s =>
|
|
s.id === 'preprocessing'
|
|
? { ...s, status: 'error' as const, error: errorMessage }
|
|
: s
|
|
));
|
|
|
|
// Definir erro global
|
|
setError(error);
|
|
|
|
chatLog.error('Preprocessing failed:', error);
|
|
// Erro já será mostrado no chat-input, apenas retornar
|
|
return;
|
|
}
|
|
|
|
// ========== TERMINAL LOGGING ==========
|
|
chatLog.info('\n========== NEW QUERY ==========');
|
|
chatLog.info(`User Query: "${content}"`);
|
|
chatLog.info(`Model: ${selectedModel}`);
|
|
chatLog.info(`Web Search: ${webSearch.isEnabled ? 'ENABLED' : 'DISABLED'}`);
|
|
chatLog.info(`Intent: ${preprocessed.intent}`);
|
|
chatLog.info(`Should Search: ${preprocessed.shouldSearch}`);
|
|
chatLog.info(`Timestamp: ${new Date().toISOString()}`);
|
|
|
|
setShowContextDebug(false);
|
|
setShowProcessDebug(false);
|
|
setLastWebContext('');
|
|
setLastContextSources([]);
|
|
setLastUserQuery(content); // Store for copy logs
|
|
setLogsCopied(false);
|
|
|
|
// Generate ID if new session (but don't create card yet - wait for title)
|
|
if (!currentSessionId) {
|
|
const newId = crypto.randomUUID();
|
|
setCurrentSessionId(newId);
|
|
chatLog.info(`New session created: ${newId}`);
|
|
}
|
|
|
|
// Função auxiliar para executar pesquisa web
|
|
const executeWebResearch = async (
|
|
content: string,
|
|
preprocessed: PreprocessedQuery
|
|
): Promise<{
|
|
knowledgeBaseContext: string;
|
|
scrapedSources: ScrapedContent[];
|
|
validationReport: string;
|
|
}> => {
|
|
let knowledgeBaseContext = '';
|
|
let scrapedSources: ScrapedContent[] = [];
|
|
let validationReport = '';
|
|
|
|
// Roteamento inteligente: só buscar se shouldSearch for true
|
|
if (webSearch.isEnabled && preprocessed.shouldSearch) {
|
|
chatLog.info('\n--- STARTING DEEP RESEARCH PIPELINE ---');
|
|
|
|
// Adicionar mensagem de processo: Pesquisa Web
|
|
addThinkingMessage('web-research', 'Pesquisando na Web...', 'running');
|
|
const webResearchStart = Date.now();
|
|
|
|
try {
|
|
webSearch.reset();
|
|
deepResearch.reset();
|
|
|
|
// 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', {
|
|
label: `Buscando ${searchPlan.length} consultas...`,
|
|
progress: 10,
|
|
details: `Executando busca progressiva com fallback automático...`,
|
|
});
|
|
|
|
const maxResults = Math.max(2, Math.floor(settings.webSearch.maxResults / searchPlan.length));
|
|
chatLog.info(`Max results per query: ${maxResults}`);
|
|
|
|
// Executar busca progressiva para cada query do plano
|
|
const searchPromises = searchPlan.map(async (query, idx) => {
|
|
chatLog.info(` → Starting progressive search ${idx + 1}/${searchPlan.length}: "${query}"`);
|
|
|
|
// Atualizar progresso
|
|
updateThinkingMessage('web-research', {
|
|
details: `Buscando consulta ${idx + 1}/${searchPlan.length}: "${query}"...`,
|
|
progress: 10 + (idx * 20 / searchPlan.length),
|
|
});
|
|
|
|
try {
|
|
// Executar busca progressiva
|
|
const fallbackResult = await executeProgressiveSearch(
|
|
query,
|
|
async (q, limit) => {
|
|
return await webSearch.smartSearchRag(q, limit);
|
|
},
|
|
selectedModel,
|
|
{
|
|
maxRounds: 3, // Máximo 3 rodadas por query
|
|
maxResultsPerRound: maxResults,
|
|
maxTotalResults: 30, // Máximo 30 resultados por query
|
|
minRelevanceScore: 0.3,
|
|
enableQueryExpansion: true,
|
|
}
|
|
);
|
|
|
|
// Log do resultado
|
|
chatLog.info(` ✓ Progressive search ${idx + 1} complete:`);
|
|
chatLog.info(` - Success: ${fallbackResult.success}`);
|
|
chatLog.info(` - Total results: ${fallbackResult.scrapedSources.length}`);
|
|
chatLog.info(` - Total analyzed: ${fallbackResult.totalResultsAnalyzed}`);
|
|
chatLog.info(` - Attempts: ${fallbackResult.attempts.length}`);
|
|
chatLog.info(` - Used fallback: ${fallbackResult.usedFallback}`);
|
|
|
|
// Log detalhado de cada resultado
|
|
fallbackResult.scrapedSources.forEach((result, resultIdx) => {
|
|
chatLog.info(` → Result ${resultIdx + 1}: ${result.title}`);
|
|
chatLog.info(` URL: ${result.url}`);
|
|
const contentLength = result.markdown?.length || 0;
|
|
chatLog.info(` Content length: ${contentLength} chars`);
|
|
});
|
|
|
|
// Adicionar resultados à Knowledge Base
|
|
if (fallbackResult.scrapedSources.length > 0) {
|
|
deepResearch.addToKnowledgeBase(query, fallbackResult.scrapedSources);
|
|
}
|
|
|
|
// Se usou fallback, notificar no frontend
|
|
if (fallbackResult.usedFallback) {
|
|
updateThinkingMessage('web-research', {
|
|
details: `Consulta ${idx + 1}: Nenhum resultado relevante encontrado após ${fallbackResult.attempts.length} tentativas. Usando conhecimento interno.`,
|
|
});
|
|
}
|
|
|
|
return { query, results: fallbackResult.scrapedSources, fallbackResult };
|
|
} catch (err) {
|
|
chatLog.error(` ✗ Progressive search ${idx + 1} failed: ${err}`);
|
|
return { query, results: [] as ScrapedContent[], fallbackResult: null };
|
|
}
|
|
});
|
|
|
|
await Promise.all(searchPromises);
|
|
chatLog.info(`All searches complete. Knowledge Base size: ${deepResearch.state.knowledgeBase.length}`);
|
|
|
|
// Coletar todas as fontes para exibição
|
|
scrapedSources = deepResearch.state.knowledgeBase.map(entry => ({
|
|
url: entry.sourceUrl,
|
|
title: entry.title,
|
|
markdown: entry.content,
|
|
snippet: entry.content.substring(0, 200)
|
|
}));
|
|
|
|
// Adicionar mensagem: Fontes encontradas
|
|
if (scrapedSources.length > 0) {
|
|
addThinkingMessage('sources-found', `Fontes encontradas (${scrapedSources.length})`, 'completed');
|
|
// Atualizar com fontes
|
|
updateThinkingMessage('sources-found', {
|
|
sources: scrapedSources.map(s => ({ url: s.url, title: s.title })),
|
|
});
|
|
}
|
|
|
|
if (deepResearch.state.knowledgeBase.length > 0) {
|
|
// Passo 3: Validação e Contexto
|
|
chatLog.info('\nStep 3: Validation');
|
|
deepResearch.setStep('aggregating');
|
|
|
|
// Adicionar mensagem: Processamento
|
|
addThinkingMessage('processing', 'Processando contexto...', 'running');
|
|
validationReport = await deepResearch.validate(selectedModel, content);
|
|
chatLog.info(`Validation Report Length: ${validationReport.length} chars`);
|
|
|
|
// Passo 4: Obter contexto curado (usando versão otimizada)
|
|
chatLog.info('\nStep 4: Getting Curated Context (Optimized)');
|
|
|
|
// Calcular tokens disponíveis dinamicamente
|
|
const { getModelContextInfo } = await import('@/lib/model-context');
|
|
const contextInfo = await getModelContextInfo(selectedModel, systemPrompt, messages);
|
|
const availableTokens = contextInfo.recommendedContextWindow;
|
|
|
|
chatLog.info(`Model Context Window: ${contextInfo.maxContextWindow} tokens`);
|
|
chatLog.info(`Available Tokens for KB: ${availableTokens} tokens`);
|
|
|
|
const optimizedResult = deepResearch.getCuratedContextOptimized(content, availableTokens);
|
|
knowledgeBaseContext = optimizedResult.context;
|
|
|
|
chatLog.info(`Knowledge Base Context Length: ${knowledgeBaseContext.length} chars`);
|
|
chatLog.info(`Condensation Method: ${optimizedResult.result.method}`);
|
|
chatLog.info(`Chunks Used: ${optimizedResult.result.chunksUsed} / ${optimizedResult.result.chunksTotal}`);
|
|
chatLog.info(`Compression Ratio: ${(optimizedResult.result.compressionRatio * 100).toFixed(1)}%`);
|
|
|
|
// Log preview do contexto se não for muito longo
|
|
if (knowledgeBaseContext.length > 0) {
|
|
if (knowledgeBaseContext.length > 2000) {
|
|
chatLog.info(`Context Preview (first 2000 chars):\n${knowledgeBaseContext.substring(0, 2000)}...`);
|
|
} else {
|
|
chatLog.info(`Full Context:\n${knowledgeBaseContext}`);
|
|
}
|
|
|
|
// Validar contexto
|
|
const { validateCondensedContext } = await import('@/lib/knowledge-base-processor');
|
|
const validation = validateCondensedContext(optimizedResult.result, content);
|
|
|
|
if (!validation.isValid) {
|
|
chatLog.error('Context validation failed:', validation.warnings.join(', '));
|
|
} else if (validation.warnings.length > 0) {
|
|
validation.warnings.forEach(warning => {
|
|
chatLog.warn(`⚠️ ${warning}`);
|
|
});
|
|
} else {
|
|
chatLog.info('✓ Context validation passed');
|
|
}
|
|
} else {
|
|
chatLog.warn('⚠️ Knowledge Base Context is empty!');
|
|
}
|
|
|
|
setLastWebContext(knowledgeBaseContext);
|
|
setLastContextSources(scrapedSources);
|
|
|
|
// Atualizar mensagem: Processamento concluído
|
|
updateThinkingMessage('processing', {
|
|
status: 'completed',
|
|
label: 'Contexto processado',
|
|
});
|
|
} else {
|
|
chatLog.warn('⚠️ No results found in Knowledge Base');
|
|
}
|
|
|
|
// Atualizar mensagem: Pesquisa Web concluída
|
|
updateThinkingMessage('web-research', {
|
|
status: 'completed',
|
|
duration: Date.now() - webResearchStart,
|
|
});
|
|
} 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();
|
|
scrapedSources = results;
|
|
setLastWebContext(knowledgeBaseContext);
|
|
setLastContextSources(scrapedSources);
|
|
}
|
|
} else {
|
|
chatLog.info('Simple query returned NO_SEARCH, skipping web search');
|
|
}
|
|
}
|
|
|
|
} catch (error) {
|
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
chatLog.error(`❌ Error in Deep Research pipeline: ${errorMsg}`);
|
|
|
|
// Atualizar mensagem de processo com erro
|
|
updateThinkingMessage('web-research', {
|
|
status: 'error',
|
|
error: errorMsg,
|
|
duration: Date.now() - webResearchStart,
|
|
});
|
|
|
|
// Atualizar step com erro (para compatibilidade)
|
|
setProcessSteps(prev => prev.map(s =>
|
|
s.id === 'web-research'
|
|
? { ...s, status: 'error' as const, error: errorMsg }
|
|
: s
|
|
));
|
|
|
|
// Definir erro global
|
|
setError(error);
|
|
}
|
|
} else {
|
|
if (!webSearch.isEnabled) {
|
|
chatLog.info('Web Search disabled, skipping research pipeline');
|
|
} else if (!preprocessed.shouldSearch) {
|
|
chatLog.info(`Intent "${preprocessed.intent}" does not require web search, skipping research pipeline`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
knowledgeBaseContext,
|
|
scrapedSources,
|
|
validationReport,
|
|
};
|
|
};
|
|
|
|
// ========== 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() },
|
|
]);
|
|
}
|
|
|
|
const researchStart = Date.now();
|
|
// Executar pesquisa web
|
|
const { knowledgeBaseContext, scrapedSources, validationReport } = await executeWebResearch(
|
|
content,
|
|
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() },
|
|
]);
|
|
|
|
// ========== MONTAR SYSTEM PROMPT COM CONTEXTO TEMPORAL E VALIDAÇÃO ==========
|
|
// 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;
|
|
|
|
// Se tivermos uma Knowledge Base, usar o prompt STRICT_GENERATION
|
|
// Verificar se houve fallback (nenhum resultado relevante encontrado)
|
|
const usedFallback = knowledgeBaseContext.length === 0 || knowledgeBaseContext.length < 100;
|
|
|
|
if (knowledgeBaseContext && knowledgeBaseContext.length > 100) {
|
|
const strictPrompt = DEEP_RESEARCH_PROMPTS.STRICT_GENERATION
|
|
.replace('{{knowledgeBase}}', knowledgeBaseContext)
|
|
.replace('{{validationReport}}', validationReport || 'Nenhuma validação disponível.')
|
|
.replace('{{userQuery}}', content);
|
|
|
|
enhancedSystemPrompt = strictPrompt;
|
|
} else if (usedFallback) {
|
|
// Fallback: usar prompt sem contexto web, mas informando que não encontrou fontes
|
|
enhancedSystemPrompt = `${systemPrompt}
|
|
|
|
## ⚠️ AVISO IMPORTANTE
|
|
|
|
Não foi possível encontrar informações relevantes nas fontes web consultadas após múltiplas tentativas de busca.
|
|
|
|
**INSTRUÇÕES:**
|
|
- Responda usando APENAS seu conhecimento interno (treinamento)
|
|
- Seja honesto e diga: "Não encontrei essa informação nas fontes consultadas."
|
|
- Se tiver conhecimento sobre o tópico, compartilhe, mas deixe claro que não há fontes externas verificadas
|
|
- Não invente informações ou cite fontes que não foram consultadas
|
|
|
|
## DATA E HORA ATUAL DO SISTEMA
|
|
|
|
**DATA/HORA FORMATADA:** ${currentDateTime}
|
|
|
|
Use esta data exata para referências temporais.`;
|
|
}
|
|
// Fallback para lógica antiga se não tiver Knowledge Base
|
|
else if (knowledgeBaseContext) {
|
|
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").
|
|
- NÃO cite fontes no meio do texto. Use as informações do contexto web naturalmente, sem mencionar [1], [2], [3] ou outras referências numéricas.
|
|
- 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
|
|
${knowledgeBaseContext}
|
|
|
|
---
|
|
`;
|
|
} 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.
|
|
|
|
---
|
|
`;
|
|
}
|
|
|
|
// ========== TERMINAL LOGGING: PROMPT INFO ==========
|
|
chatLog.info('\n--- PROMPT CONSTRUCTION ---');
|
|
chatLog.info(`System Prompt Length: ${enhancedSystemPrompt.length} chars`);
|
|
chatLog.info(`Has Knowledge Base Context: ${knowledgeBaseContext && knowledgeBaseContext.length > 100 ? 'YES' : 'NO'}`);
|
|
chatLog.info(`Using STRICT_GENERATION prompt: ${knowledgeBaseContext && knowledgeBaseContext.length > 100 ? 'YES' : 'NO'}`);
|
|
|
|
let finalUserContent = content;
|
|
if (knowledgeBaseContext && knowledgeBaseContext.length > 100) {
|
|
finalUserContent = `[KNOWLEDGE BASE - ÚNICA FONTE DE VERDADE]\n${knowledgeBaseContext}\n[/KNOWLEDGE BASE]\n\nResponda a pergunta usando APENAS os dados acima. Se a informação não estiver na Knowledge Base, diga "Não encontrei essa informação nas fontes consultadas."\n\nPergunta: ${content}`;
|
|
}
|
|
|
|
chatLog.info(`Final User Content Length: ${finalUserContent.length} chars`);
|
|
chatLog.info('\n--- SENDING TO LLM ---');
|
|
chatLog.info('Starting response generation...');
|
|
|
|
// Adicionar mensagem de processo: Geração de Resposta
|
|
addThinkingMessage('response-generation', 'Gerando resposta...', 'running');
|
|
|
|
// Adicionar mensagem vazia do assistente para feedback visual imediato
|
|
// Esta será preenchida quando o streaming começar
|
|
setMessages(prev => [...prev, { role: 'assistant', content: '' }]);
|
|
|
|
// Enviar o conteúdo original para UI, mas com override no payload para incluir contexto
|
|
const responseStart = Date.now();
|
|
await sendMessage(content, selectedModel, enhancedSystemPrompt, {
|
|
payloadContentOverride: finalUserContent,
|
|
});
|
|
|
|
// Atualizar mensagem de processo: Geração concluída
|
|
updateThinkingMessage('response-generation', {
|
|
status: 'completed',
|
|
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
|
|
));
|
|
|
|
chatLog.info('✅ Response generation complete');
|
|
chatLog.info('========== QUERY COMPLETE ==========\n');
|
|
|
|
// Persistir fontes na mensagem do assistente (metadata)
|
|
if (scrapedSources.length > 0) {
|
|
// Aguardar um pouco para garantir que o sendMessage iniciou e criou a mensagem do assistente
|
|
// O sendMessage é async mas retorna void enquanto a stream acontece.
|
|
// O setMessages dentro dele adiciona a mensagem do assistente.
|
|
|
|
// Nota: Como sendMessage roda em background (streaming), não podemos garantir que a mensagem
|
|
// do assistente já existe aqui imediatamente se não esperarmos.
|
|
// Mas o sendMessage faz `setMessages` com assistant vazio logo no início.
|
|
|
|
// Vamos atualizar o estado usando callback para garantir que pegamos o mais recente
|
|
setMessages(prev => {
|
|
const newMessages = [...prev];
|
|
const lastMsgIndex = newMessages.length - 1;
|
|
if (lastMsgIndex >= 0 && newMessages[lastMsgIndex].role === 'assistant') {
|
|
const lastMsg = newMessages[lastMsgIndex];
|
|
newMessages[lastMsgIndex] = {
|
|
...lastMsg,
|
|
metadata: {
|
|
...(lastMsg.metadata || {}),
|
|
sources: scrapedSources
|
|
}
|
|
};
|
|
}
|
|
return newMessages;
|
|
});
|
|
}
|
|
|
|
// Marcar como completo quando começar a streamar
|
|
setTimeout(() => setThinkingStep('complete'), 100);
|
|
};
|
|
|
|
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 (isChatsSidebarCollapsed) {
|
|
panel.expand();
|
|
} else {
|
|
panel.collapse();
|
|
}
|
|
}
|
|
};
|
|
|
|
// Copy Logs to Clipboard
|
|
const handleCopyLogs = async () => {
|
|
const logs = deepResearch.state.logs;
|
|
const lastAssistantMessage = messages.filter(m => m.role === 'assistant').pop();
|
|
|
|
let logText = `# Deep Research Debug Log\n\n`;
|
|
logText += `## User Query\n${lastUserQuery}\n\n`;
|
|
logText += `## Model\n${selectedModel}\n\n`;
|
|
logText += `## Process Logs\n\n`;
|
|
|
|
logs.forEach((log, idx) => {
|
|
logText += `### ${idx + 1}. Stage: ${log.stage.toUpperCase()}\n`;
|
|
logText += `- **Time**: ${new Date(log.timestamp).toLocaleTimeString()}\n`;
|
|
logText += `- **Input**: ${log.input.substring(0, 500)}${log.input.length > 500 ? '...' : ''}\n`;
|
|
if (log.rawOutput) {
|
|
logText += `- **Raw Output**: ${log.rawOutput.substring(0, 500)}${log.rawOutput.length > 500 ? '...' : ''}\n`;
|
|
}
|
|
if (log.parsedOutput) {
|
|
logText += `- **Parsed Output**: \`\`\`json\n${JSON.stringify(log.parsedOutput, null, 2)}\n\`\`\`\n`;
|
|
}
|
|
if (log.error) {
|
|
logText += `- **Error**: ${log.error}\n`;
|
|
}
|
|
logText += '\n';
|
|
});
|
|
|
|
logText += `## AI Response\n`;
|
|
logText += lastAssistantMessage?.content || '[No response yet]';
|
|
logText += '\n\n---\n';
|
|
logText += `Generated at: ${new Date().toISOString()}`;
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(logText);
|
|
setLogsCopied(true);
|
|
setTimeout(() => setLogsCopied(false), 2000);
|
|
} catch (err) {
|
|
console.error('Failed to copy logs:', err);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="h-screen w-full bg-background overflow-hidden flex">
|
|
{/* Left Sidebar (Nav) */}
|
|
<ResizablePanelGroup direction="horizontal" className="flex-1">
|
|
|
|
<ResizablePanel
|
|
defaultSize={4}
|
|
minSize={4}
|
|
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" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="rounded-lg text-muted-foreground hover:text-foreground">
|
|
<Server className="w-5 h-5" />
|
|
</Button>
|
|
<div className="flex-1" />
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
|
className="rounded-lg text-muted-foreground hover:text-foreground"
|
|
>
|
|
{mounted ? (theme === "dark" ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />) : null}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="rounded-lg text-muted-foreground hover:text-foreground"
|
|
onClick={() => router.push('/settings')}
|
|
>
|
|
<Settings className="w-5 h-5" />
|
|
</Button>
|
|
</ResizablePanel>
|
|
|
|
<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={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">
|
|
{isChatsSidebarCollapsed ? (
|
|
<Button variant="ghost" size="icon" onClick={toggleChatsSidebar} className="h-8 w-8">
|
|
<PanelLeftOpen className="w-4 h-4" />
|
|
</Button>
|
|
) : (
|
|
<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 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-[420px] flex items-center gap-2">
|
|
<div className="flex-1">
|
|
<Select
|
|
value={selectedModel || undefined}
|
|
onValueChange={(v) => {
|
|
if (v === "__add_model__") {
|
|
setShowDownloadDialog(true);
|
|
// Não alterar selectedModel quando abrir dialog
|
|
} else {
|
|
setSelectedModel(v);
|
|
settings.setSelectedModel(v);
|
|
}
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-9">
|
|
<SelectValue placeholder="Selecione um modelo..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{models.map(m => (
|
|
<SelectItem key={m.name} value={m.name}>
|
|
<span className="flex items-center justify-between w-full">
|
|
<span className="truncate">{m.name}</span>
|
|
<span className="ml-3 text-xs text-muted-foreground">{m.size}</span>
|
|
</span>
|
|
</SelectItem>
|
|
))}
|
|
<SelectItem value="__add_model__" className="text-primary font-medium">
|
|
<span className="flex items-center gap-2">
|
|
<Plus className="h-4 w-4" />
|
|
Adicionar modelo...
|
|
</span>
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<Button variant="outline" size="icon" className="h-9 w-9" onClick={refresh} title="Atualizar modelos">
|
|
<Loader2 className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Messages Area with Floating Input Layout */}
|
|
<div className="flex-1 relative h-full overflow-hidden flex flex-col">
|
|
{/* Scrollable Content Area */}
|
|
<div className="flex-1 overflow-y-auto scroll-smooth w-full">
|
|
<div className="max-w-3xl mx-auto w-full px-4 md:px-0 pt-6 pb-4">
|
|
{messages.length === 0 ? (
|
|
<div className="min-h-[50vh] flex flex-col items-center justify-center text-muted-foreground space-y-6">
|
|
<div className="p-6 rounded-2xl bg-muted/30 ring-1 ring-border/50 shadow-sm">
|
|
<MessageSquare className="w-8 h-8 text-primary/60" />
|
|
</div>
|
|
<div className="text-center space-y-2">
|
|
<p className="text-lg font-medium text-primary/80">Como posso ajudar você hoje?</p>
|
|
<p className="text-sm text-muted-foreground">Selecione um modelo e comece uma conversa.</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-6">
|
|
{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;
|
|
|
|
return (
|
|
<div key={i} className="animate-in fade-in slide-in-from-bottom-4 duration-500">
|
|
<ChatMessage message={msg} isStreaming={isAssistantStreaming} />
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{isLoading && (
|
|
<div className="px-4 py-2 text-sm text-muted-foreground animate-pulse flex items-center gap-2">
|
|
<div className="w-2 h-2 bg-primary/50 rounded-full animate-bounce" />
|
|
<span>Pensando...</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Elemento invisível para scroll automático */}
|
|
<div ref={messagesEndRef} className="h-1" />
|
|
</div>
|
|
|
|
{/* Debug Info (Moved inside scroll) */}
|
|
{(deepResearch.state.logs.length > 0 || lastWebContext) && (
|
|
<div className="mt-12 pt-6 border-t border-dashed border-muted/50 opacity-70 hover:opacity-100 transition-opacity">
|
|
<div className="space-y-4">
|
|
{/* Process Log Toggle */}
|
|
{deepResearch.state.logs.length > 0 && (
|
|
<div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowProcessDebug((prev) => !prev)}
|
|
className="flex items-center gap-2 text-xs font-medium text-muted-foreground hover:text-primary transition-colors"
|
|
>
|
|
<ScrollText className="w-3 h-3" />
|
|
Debug: Processo de Pensamento
|
|
<ChevronDown className={`w-3 h-3 transition-transform ${showProcessDebug ? 'rotate-180' : ''}`} />
|
|
</button>
|
|
{showProcessDebug && (
|
|
<div className="mt-2 space-y-2 p-4 rounded-lg bg-muted/30 text-xs border border-muted/50 max-h-60 overflow-y-auto">
|
|
<div className="flex justify-end mb-2">
|
|
<Button variant="ghost" size="sm" onClick={handleCopyLogs} className="h-6 px-2 text-[10px]">
|
|
{logsCopied ? <Check className="w-3 h-3 mr-1" /> : <Copy className="w-3 h-3 mr-1" />} Copiar Logs
|
|
</Button>
|
|
</div>
|
|
{deepResearch.state.logs.map((log, idx) => (
|
|
<div key={idx} className="grid grid-cols-[60px_1fr] gap-2 border-b border-muted/20 pb-2 last:border-0">
|
|
<span className="text-muted-foreground uppercase text-[10px]">{log.stage}</span>
|
|
<span>{log.input ? (log.input.length > 50 ? log.input.substring(0, 50) + '...' : log.input) : '-'}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Context Debug Toggle */}
|
|
{lastWebContext && (
|
|
<div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowContextDebug((prev) => !prev)}
|
|
className="flex items-center gap-2 text-xs font-medium text-muted-foreground hover:text-primary transition-colors"
|
|
>
|
|
<Globe className="w-3 h-3" />
|
|
Debug: Contexto Web ({lastContextSources.length} fontes)
|
|
<ChevronDown className={`w-3 h-3 transition-transform ${showContextDebug ? 'rotate-180' : ''}`} />
|
|
</button>
|
|
{showContextDebug && (
|
|
<div className="mt-2 p-4 rounded-lg bg-muted/30 text-xs border border-muted/50 space-y-3">
|
|
<div className="max-h-32 overflow-y-auto space-y-1">
|
|
{lastContextSources.map((s, i) => (
|
|
<a key={i} href={s.url} target="_blank" className="block hover:underline text-primary truncate">{s.title || s.url}</a>
|
|
))}
|
|
</div>
|
|
<div className="border-t border-muted/20 pt-2">
|
|
<p className="mb-1 font-semibold">Preview Contexto:</p>
|
|
<pre className="max-h-32 overflow-y-auto p-2 bg-background rounded border text-[10px]">{lastWebContext.substring(0, 500)}...</pre>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Spacer for Bottom Input */}
|
|
<div className="h-32 md:h-40 w-full shrink-0" ref={messagesEndRef} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Fixed Input Container */}
|
|
<div className="absolute bottom-0 left-0 right-0 z-20 pointer-events-none">
|
|
{/* Gradient Overlay */}
|
|
<div className="absolute inset-0 -top-20 bg-gradient-to-t from-background via-background/90 to-transparent pointer-events-none" />
|
|
|
|
{/* Input Wrapper */}
|
|
<div className="relative max-w-3xl mx-auto px-4 pb-6 pt-4 pointer-events-auto">
|
|
<ChatInput
|
|
onSend={handleSend}
|
|
onStop={stop}
|
|
isLoading={isLoading}
|
|
webSearchEnabled={webSearch.isEnabled}
|
|
onWebSearchToggle={webSearch.setEnabled}
|
|
categories={settings.webSearch.categories}
|
|
onToggleCategory={(id, enabled) => {
|
|
const cat = settings.webSearch.categories.find(c => c.id === id);
|
|
if (!cat) return;
|
|
settings.updateCategory({ ...cat, enabled });
|
|
}}
|
|
/>
|
|
<div className="text-center mt-2">
|
|
<p className="text-[10px] text-muted-foreground/60">OllaHub pode cometer erros. Verifique informações importantes.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</ResizablePanel>
|
|
</ResizablePanelGroup>
|
|
|
|
<ModelDownloadDialog
|
|
open={showDownloadDialog}
|
|
onOpenChange={(open) => {
|
|
setShowDownloadDialog(open);
|
|
// Se fechando, garantir que o Select não mantenha o valor especial
|
|
if (!open && selectedModel === "__add_model__") {
|
|
setSelectedModel("");
|
|
}
|
|
}}
|
|
onSuccess={(modelName) => {
|
|
// Atualizar lista de modelos
|
|
refresh();
|
|
// Selecionar o modelo recém-baixado após um pequeno delay para garantir que a lista foi atualizada
|
|
setTimeout(() => {
|
|
setSelectedModel(modelName);
|
|
settings.setSelectedModel(modelName);
|
|
}, 500);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|