diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f915ae1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,185 @@ +name: Build and Release + +on: + push: + branches: + - main + - master + workflow_dispatch: + +env: + TAURI_VERSION: "2.9.4" + +jobs: + build: + permissions: + contents: write + strategy: + fail-fast: false + matrix: + include: + - platform: 'windows-latest' + target: 'x86_64-pc-windows-msvc' + bundle: 'msi' + - platform: 'macos-latest' + target: 'aarch64-apple-darwin' + bundle: 'dmg' + - platform: 'macos-latest' + target: 'x86_64-apple-darwin' + bundle: 'dmg' + - platform: 'ubuntu-latest' + target: 'x86_64-unknown-linux-gnu' + bundle: 'appimage' + + runs-on: ${{ matrix.platform }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 8 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + targets: ${{ matrix.target }} + + - name: Install dependencies (Ubuntu only) + if: matrix.platform == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev patchelf + + - name: Install frontend dependencies + run: pnpm install --frozen-lockfile + + - name: Generate changelog + run: pnpm generate-changelog + + - name: Build Tauri app + run: | + cd src-tauri + cargo build --release --target ${{ matrix.target }} + + - name: Build Tauri bundle + env: + TAURI_PRIVATE_KEY: ${{ secrets.TAURI_UPDATER_PRIVATE_KEY }} + run: | + cd src-tauri + if [ -n "$TAURI_PRIVATE_KEY" ]; then + echo "$TAURI_PRIVATE_KEY" > /tmp/private.key + cargo tauri build --target ${{ matrix.target }} --config '{"updater":{"active":true}}' + rm /tmp/private.key + else + cargo tauri build --target ${{ matrix.target }} + fi + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.platform }}-${{ matrix.target }} + path: | + src-tauri/target/${{ matrix.target }}/release/bundle/**/*.${{ matrix.bundle }} + src-tauri/target/${{ matrix.target }}/release/bundle/**/*.exe + src-tauri/target/${{ matrix.target }}/release/bundle/**/*.AppImage + src-tauri/target/${{ matrix.target }}/release/bundle/**/*.deb + retention-days: 7 + + create-release: + needs: build + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 8 + + - name: Get version + id: get_version + run: | + VERSION=$(node -p "require('./package.json').version") + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "tag=v$VERSION" >> $GITHUB_OUTPUT + + - name: Generate changelog for release + run: pnpm generate-changelog + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Extract release notes + id: extract_notes + run: | + if [ -f "data/changelog.json" ]; then + NOTES=$(node -e " + const fs = require('fs'); + try { + const changelog = JSON.parse(fs.readFileSync('data/changelog.json', 'utf-8')); + const latestRelease = changelog.releases[0]; + if (latestRelease) { + let notes = '## ' + (latestRelease.version === 'unreleased' ? 'Próxima Versão' : 'v' + latestRelease.version) + '\\n\\n'; + notes += '**Data:** ' + latestRelease.date + '\\n\\n'; + notes += '### Mudanças\\n\\n'; + latestRelease.commits.forEach(commit => { + const icon = commit.type === 'feat' ? '✨' : '🐛'; + notes += '- ' + icon + ' **' + (commit.type === 'feat' ? 'Nova Funcionalidade' : 'Correção') + ':** ' + commit.message + '\\n'; + }); + console.log(notes); + } else { + console.log('## Release ${{ steps.get_version.outputs.version }}'); + } + } catch (e) { + console.log('## Release ${{ steps.get_version.outputs.version }}'); + } + ") + echo "notes<> $GITHUB_OUTPUT + echo "$NOTES" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + else + echo "notes=## Release ${{ steps.get_version.outputs.version }}" >> $GITHUB_OUTPUT + fi + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.get_version.outputs.tag }} + name: Release ${{ steps.get_version.outputs.tag }} + body: | + ${{ steps.extract_notes.outputs.notes }} + + ## Downloads + + Baixe o instalador para sua plataforma nos assets abaixo. + draft: false + files: artifacts/**/* + generate_release_notes: false + diff --git a/.gitignore b/.gitignore index 5ef6a52..2146da4 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts +~/.tauri/ollahub.key diff --git a/IMPLEMENTATION_GUIDE.md b/IMPLEMENTATION_GUIDE.md new file mode 100644 index 0000000..a37f609 --- /dev/null +++ b/IMPLEMENTATION_GUIDE.md @@ -0,0 +1,309 @@ +# Guia de Implementação: System Tray, Background Mode e Task Scheduler + +## Status da Implementação + +### ✅ Concluído + +1. **Estrutura de Tasks** (`src-tauri/src/scheduler.rs`) + - `SentinelTask` com suporte a múltiplos tipos de ações + - Persistência em `tasks.json` + - Gerenciamento de estado thread-safe + +2. **Cliente Ollama Headless** (`src-tauri/src/ollama_client.rs`) + - Comunicação direta com Ollama via `reqwest` + - Suporte a streaming + - Verificação de conexão + +3. **Executor de Tasks** (`src-tauri/src/task_executor.rs`) + - Execução de `SearchAndSummarize` + - Execução de `JustPing` + - Execução de `CustomPrompt` + - Integração com file locks para escrita segura + +4. **Scheduler Loop** (`src-tauri/src/scheduler_loop.rs`) + - Integração com `tokio-cron-scheduler` + - Recarregamento dinâmico de tasks + - Execução assíncrona em background + +### 🔨 Pendente (Próximos Passos) + +#### 1. Comandos Tauri para CRUD de Tasks + +Adicione ao `src-tauri/src/lib.rs`: + +```rust +use scheduler::{SchedulerService, SchedulerState, SentinelTask, TaskAction}; +use chrono::Utc; +use uuid::Uuid; + +#[command] +async fn create_task( + scheduler: State<'_, SchedulerState>, + label: String, + cron_schedule: String, + action: TaskAction, +) -> Result { + let task = SentinelTask { + id: Uuid::new_v4().to_string(), + label, + cron_schedule, + action, + enabled: true, + last_run: None, + created_at: Utc::now(), + updated_at: Utc::now(), + }; + + let mut sched = scheduler.lock().await; + sched.upsert_task(task.clone())?; + Ok(task.id) +} + +#[command] +async fn list_tasks( + scheduler: State<'_, SchedulerState>, +) -> Result, String> { + let sched = scheduler.lock().await; + Ok(sched.list_tasks()) +} + +#[command] +async fn update_task( + scheduler: State<'_, SchedulerState>, + task: SentinelTask, +) -> Result<(), String> { + let mut sched = scheduler.lock().await; + let mut updated = task; + updated.updated_at = Utc::now(); + sched.upsert_task(updated) +} + +#[command] +async fn delete_task( + scheduler: State<'_, SchedulerState>, + id: String, +) -> Result<(), String> { + let mut sched = scheduler.lock().await; + sched.remove_task(&id) +} + +#[command] +async fn toggle_task( + scheduler: State<'_, SchedulerState>, + id: String, + enabled: bool, +) -> Result<(), String> { + let mut sched = scheduler.lock().await; + if let Some(mut task) = sched.get_task(&id).cloned() { + task.enabled = enabled; + task.updated_at = Utc::now(); + sched.upsert_task(task) + } else { + Err("Task not found".to_string()) + } +} +``` + +#### 2. System Tray Setup + +No `src-tauri/src/lib.rs`, modifique o `.setup()`: + +```rust +use tauri::{Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, SystemTrayMenuItem}; + +.setup(|app| { + // ... código existente ... + + // Criar System Tray + let tray_menu = SystemTrayMenu::new() + .add_item(SystemTrayMenuItem::with_id("show", "Mostrar OllaHub", true, None)) + .add_item(SystemTrayMenuItem::with_id("pause", "Pausar Tasks", true, None)) + .add_native_item(SystemTrayMenuItem::Separator) + .add_item(SystemTrayMenuItem::with_id("quit", "Sair", true, None)); + + let system_tray = SystemTray::new().with_menu(tray_menu); + + app.handle().system_tray(system_tray)?; + + // Handler de eventos do Tray + app.handle().on_system_tray_event(|app, event| { + if let SystemTrayEvent::MenuItemClick { id, .. } = event { + match id.as_str() { + "show" => { + if let Some(window) = app.get_window("main") { + window.show().unwrap(); + window.set_focus().unwrap(); + } + } + "pause" => { + // Implementar lógica de pausar tasks + log::info!("Pausar tasks - implementar"); + } + "quit" => { + app.exit(0); + } + _ => {} + } + } + }); + + // Modificar comportamento de fechar janela + if let Some(window) = app.get_window("main") { + window.on_window_event(|event| { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + // Ocultar ao invés de fechar + if let Some(window) = event.window() { + window.hide().unwrap(); + } + api.prevent_close(); + } + }); + } + + Ok(()) +}) +``` + +#### 3. Inicializar Scheduler no Setup + +No mesmo `.setup()`, após criar o scheduler: + +```rust +// Inicializar scheduler +let scheduler_service = SchedulerService::new(app.handle().clone())?; +let scheduler_state: SchedulerState = Arc::new(Mutex::new(scheduler_service)); +let browser_state: BrowserState = app.state(); + +// Iniciar loop do scheduler em background +let app_handle = app.handle().clone(); +let scheduler_clone = scheduler_state.clone(); +let browser_clone = browser_state.clone(); +tokio::spawn(async move { + if let Err(e) = scheduler_loop::start_scheduler_loop( + app_handle, + scheduler_clone, + browser_clone, + None, // Ollama URL - pode vir do settings + ).await { + log::error!("Erro ao iniciar scheduler: {}", e); + } +}); + +// Adicionar ao manage +.manage(scheduler_state) +``` + +#### 4. Adicionar Comandos ao Invoke Handler + +```rust +.invoke_handler(tauri::generate_handler![ + // ... comandos existentes ... + create_task, + list_tasks, + update_task, + delete_task, + toggle_task, +]) +``` + +#### 5. Interface React (app/tasks/page.tsx) + +Crie uma página básica para gerenciar tasks: + +```tsx +'use client'; + +import { useState, useEffect } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; + +interface Task { + id: string; + label: string; + cron_schedule: string; + action: any; + enabled: boolean; + last_run: string | null; +} + +export default function TasksPage() { + const [tasks, setTasks] = useState([]); + + useEffect(() => { + loadTasks(); + }, []); + + const loadTasks = async () => { + try { + const loaded = await invoke('list_tasks'); + setTasks(loaded); + } catch (error) { + console.error('Erro ao carregar tasks:', error); + } + }; + + const toggleTask = async (id: string, enabled: boolean) => { + try { + await invoke('toggle_task', { id, enabled: !enabled }); + await loadTasks(); + } catch (error) { + console.error('Erro ao alternar task:', error); + } + }; + + return ( +
+

Tarefas Agendadas

+
+ {tasks.map(task => ( + + + {task.label} + + +

Cron: {task.cron_schedule}

+

Status: {task.enabled ? 'Ativa' : 'Pausada'}

+ +
+
+ ))} +
+
+ ); +} +``` + +## Dependências Adicionais Necessárias + +No `Cargo.toml`, já foram adicionadas: +- `tokio-cron-scheduler = "0.9"` +- `futures-util = "0.3"` +- `tauri-plugin-notification = "2"` + +**Nota para Linux:** Para System Tray funcionar, pode ser necessário: +```bash +# Ubuntu/Debian +sudo apt-get install libappindicator3-1 + +# Arch/Manjaro +sudo pacman -S libappindicator-gtk3 +``` + +## Próximos Passos + +1. Compilar e testar: `cd src-tauri && cargo build` +2. Corrigir erros de compilação +3. Testar criação de task via frontend +4. Verificar se scheduler executa corretamente +5. Testar System Tray em diferentes plataformas + +## Notas Importantes + +- **File Locking**: Já implementado - tasks usam o mesmo sistema de locks que salvar sessões +- **Concorrência**: Scheduler roda em thread separada, não bloqueia UI +- **Persistência**: Tasks são salvas em `app_data_dir/tasks.json` +- **Notificações**: Usa `tauri-plugin-notification` para notificações nativas + diff --git a/app/chat/page.tsx b/app/chat/page.tsx index 50cbfe3..d9b6d33 100644 --- a/app/chat/page.tsx +++ b/app/chat/page.tsx @@ -1,73 +1,1057 @@ 'use client'; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; -import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { MessageSquare, Settings, Server, Moon, Sun, PanelLeftClose, PanelLeftOpen } from "lucide-react"; +import { MessageSquare, Settings, Server, Moon, Sun, PanelLeftClose, PanelLeftOpen, Loader2, ChevronDown, Plus, ScrollText, Copy, Check, Globe, RefreshCw, Cpu } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { ChatInput } from "@/components/chat/chat-input"; import { ChatMessage } from "@/components/chat/chat-message"; -import { SystemPanel } from "@/components/chat/system-panel"; -import { PromptGeneratorDialog } from "@/components/chat/prompt-generator"; +import { ReasoningChain } from "@/components/chat/reasoning-chain"; +import { TitleBar } from "@/components/titlebar"; + +import { SidebarList } from "@/components/chat/sidebar-list"; import { useChat } from "@/hooks/use-chat"; import { useLocalModels } from "@/hooks/use-local-models"; -import { useState, useEffect, useRef } from "react"; +import { useChatStorage } from "@/hooks/use-chat-storage"; +import { useAutoLabelingModel } from "@/hooks/use-auto-labeling-model"; +import { useWebSearch } from "@/hooks/use-web-search"; +// 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 { useState, useEffect, useRef, useCallback } from "react"; import { useTheme } from "next-themes"; +import { useRouter } from "next/navigation"; import { ImperativePanelHandle } from "react-resizable-panels"; -// @ts-ignore +import { ModelDownloadDialog } from "@/components/chat/model-download-dialog"; +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 } from "@/lib/web-search-fallback"; +// @ts-expect-error - MD file import import defaultFormatPrompt from "@/data/prompts/default-format.md"; + export default function ChatPage() { - const { messages, sendMessage, isLoading, stop } = useChat(); - const { models } = useLocalModels(); + const router = useRouter(); + const { messages, setMessages, sendMessage, isLoading, stop, clearChat } = useChat(); + const { models, refresh } = useLocalModels(); const { theme, setTheme } = useTheme(); - const [selectedModel, setSelectedModel] = useState(""); - // Initialize with default format prompt - const [systemPrompt, setSystemPrompt] = useState(defaultFormatPrompt || "Você é um assistente útil e prestativo."); - const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); + const { + sessions, + currentSessionId, + setCurrentSessionId, + loadSessionHistory, + loadMoreMessages, + paginationState, + saveSession, + deleteSession, + isGeneratingTitle, + searchSessions, + isSearching, + searchQuery + } = useChatStorage(); + + const { isDownloading, progress } = useAutoLabelingModel(); + const webSearch = useWebSearch(); + const { generateQuery } = useQueryGenerator(); + const deepResearch = useDeepResearch(); + const settings = useSettingsStore(); + const { preprocess } = useQueryPreprocessor(); - const sidebarRef = useRef(null); + // 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; + }, [setMessages]); + + const updateThinkingMessage = useCallback(( + stepType: ThinkingStepType, + updates: Partial + ) => { + 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; + })); + }, [setMessages]); + + const [selectedModel, setSelectedModel] = useState(""); + const [mounted, setMounted] = useState(false); + const initializedRef = useRef(false); + // Initialize with default format prompt + const [systemPrompt] = useState(defaultFormatPrompt || "Você é um assistente útil e prestativo."); + const [isChatsSidebarCollapsed, setIsChatsSidebarCollapsed] = useState(true); // Iniciar colapsada + // Removido: thinkingStep e processSteps - agora usando mensagens thinking agrupadas + const [, setError] = useState(null); + + const chatsSidebarRef = useRef(null); + const saveTimeoutRef = useRef(null); + const lastSavedMessagesRef = useRef(0); + const messagesEndRef = useRef(null); + const scrollContainerRef = useRef(null); + const [showContextDebug, setShowContextDebug] = useState(false); + const [showProcessDebug, setShowProcessDebug] = useState(false); + const [lastWebContext, setLastWebContext] = useState(''); + const [lastContextSources, setLastContextSources] = useState([]); + const [showDownloadDialog, setShowDownloadDialog] = useState(false); + const [logsCopied, setLogsCopied] = useState(false); + const [lastUserQuery, setLastUserQuery] = useState(''); + const [searchMatchIndex, setSearchMatchIndex] = useState(0); + + // Resetar índice quando busca é limpa ou sessão muda + useEffect(() => { + if (!searchQuery || searchQuery.trim().length < 2) { + setSearchMatchIndex(0); + } + }, [searchQuery, currentSessionId]); // Auto-select first model useEffect(() => { - if (models.length > 0 && !selectedModel) { - setSelectedModel(models[0].name); - } - }, [models, selectedModel]); + setMounted(true); + }, []); - const handleSend = (content: string) => { + 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, 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.join(', ')}`); + 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, preprocess]); + + // ========== EXECUÇÃO ESPECULATIVA DE BUSCA ========== + // Heurística rápida para detectar se a query provavelmente precisa de busca web + const mightNeedWebSearch = useCallback((text: string): boolean => { + if (!webSearch.isEnabled) return false; + + const lowerText = text.toLowerCase(); + + // Padrões que indicam alta probabilidade de precisar de busca + const questionPatterns = /\b(o que|como|quando|onde|quem|porque|por que|qual|quais|quanto|quantos|me explique|me diga|existe|há)\b/i; + const recentPatterns = /\b(hoje|ontem|esta semana|este mês|este ano|atualmente|recentemente|últimas notícias|novo|novos|última|últimas|2024|2025)\b/i; + const searchPatterns = /\?$|buscar|pesquisar|encontrar|procurar|descobrir/i; + + // Se contém padrões de pergunta ou busca, provavelmente precisa de pesquisa + if (questionPatterns.test(lowerText) || recentPatterns.test(lowerText) || searchPatterns.test(lowerText)) { + chatLog.debug('[SpeculativeSearch] Query might need web search (pattern match)'); + return true; + } + + // Se tem mais de 5 palavras e termina com ?, provavelmente é uma pergunta + const wordCount = text.trim().split(/\s+/).length; + if (wordCount > 5 && text.trim().endsWith('?')) { + chatLog.debug('[SpeculativeSearch] Query might need web search (question detected)'); + return true; + } + + return false; + }, [webSearch.isEnabled]); + + const handleSend = async (content: string) => { if (!selectedModel) return; - sendMessage(content, selectedModel, systemPrompt); + + // ========== 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'); + + // Removido: setProcessSteps - agora usando mensagens thinking agrupadas + + // Limpar erros anteriores + setError(null); + + // ========== EXECUÇÃO ESPECULATIVA ========== + // Iniciar busca web especulativamente se a query parecer precisar + // Isso reduz latência sobrepondo busca com pré-processamento + let speculativeSearchPromise: Promise | null = null; + const shouldSpeculate = mightNeedWebSearch(content); + + if (shouldSpeculate) { + chatLog.info('[SpeculativeSearch] Starting speculative web search in parallel with preprocessing'); + // Iniciar busca sem await - será usada depois se confirmada + speculativeSearchPromise = webSearch.smartSearchRag(content, settings.webSearch?.maxResults || 5) + .catch(err => { + chatLog.debug(`[SpeculativeSearch] Speculative search failed (will use normal flow): ${err}`); + return [] as ScrapedContent[]; + }); + } + + // ========== 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 errorMsg = error instanceof Error ? error.message : String(error); + updateThinkingMessage('preprocessing', { + status: 'error', + error: errorMsg, + duration: Date.now() - preprocessingStart, + }); + + // Removido: setProcessSteps - agora usando mensagens thinking agrupadas + + // Definir erro global + const errorObj = error instanceof Error ? error : new Error(String(error)); + setError(errorObj); + + chatLog.error(`Preprocessing failed: ${errorObj.message}`); + // 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, + speculativeResults?: ScrapedContent[] | null + ): 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 ---'); + + // Se temos resultados especulativos, usá-los como seed inicial + if (speculativeResults && speculativeResults.length > 0) { + chatLog.info(`[SpeculativeSearch] Using ${speculativeResults.length} speculative results as seed`); + deepResearch.addToKnowledgeBase('speculative_seed', speculativeResults); + } + + // 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'); + 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`); + + // 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 { + // Obter contexto e queries enriquecidas do deep research + const context = deepResearch.state.context; + const enrichedQueries = deepResearch.state.enrichedQueries; + + // Executar busca progressiva com semantic search se disponível + const fallbackResult = await executeProgressiveSearch( + query, + async (q, limit, round = 1) => { + // Passar round para timeout adaptativo + return await webSearch.smartSearchRag(q, limit, round); + }, + selectedModel, + { + maxRounds: 3, // Máximo 3 rodadas por query + maxResultsPerRound: maxResults, + maxTotalResults: 30, // Máximo 30 resultados por query + minRelevanceScore: 0.3, + enableQueryExpansion: !enrichedQueries, // Desabilitar expansão tradicional se usar enriquecimento + useSemanticSearch: !!enrichedQueries, // Usar semantic search se temos queries enriquecidas + context: context || undefined, + enrichedQueries: enrichedQueries || undefined, + initialTimeout: settings.webSearch.timeout || 10000, // Usar timeout das configurações + maxTimeoutPerQuery: 25000, // 25s máximo por query + maxTimeoutPerRound: 15000, // 15s máximo por round + } + ); + + // 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.`, + }); + } + + // Se houve falhas mas ainda tem resultados, avisar discretamente + const failedRounds = fallbackResult.attempts.filter(a => a.results.length === 0); + if (failedRounds.length > 0 && fallbackResult.scrapedSources.length > 0) { + updateThinkingMessage('web-research', { + details: `Consulta ${idx + 1}: Algumas tentativas falharam, mas ${fallbackResult.scrapedSources.length} resultados foram encontrados.`, + }); + } + + return { query, results: fallbackResult.scrapedSources, fallbackResult }; + } catch (err) { + // Logar erro mas continuar (não quebrar o fluxo) + const errorMsg = err instanceof Error ? err.message : String(err); + chatLog.warn(` ⚠️ Progressive search ${idx + 1} failed (continuando): ${errorMsg}`); + + // Atualizar mensagem de processo com aviso + updateThinkingMessage('web-research', { + details: `Consulta ${idx + 1}: Erro na busca (${errorMsg}). Continuando com outras consultas...`, + }); + + 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, + content: entry.content, + 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'); + 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}`); + 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) { + // Tratar erro graciosamente sem quebrar o fluxo + const errorMsg = error instanceof Error ? error.message : String(error); + chatLog.warn(`⚠️ Error in Deep Research pipeline (continuing with partial results): ${errorMsg}`); + + // Atualizar mensagem de processo com aviso (não erro fatal) + updateThinkingMessage('web-research', { + status: 'completed', // Marcar como completed mesmo com erro parcial + details: `Alguns erros ocorreram durante a pesquisa: ${errorMsg}. Continuando com resultados parciais...`, + duration: Date.now() - webResearchStart, + }); + + // Não definir erro global - permitir que o fluxo continue + // O sistema continuará com conhecimento interno se necessário + } + } 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) ========== + // Removido: setThinkingStep e setProcessSteps - agora usando mensagens thinking agrupadas + + // Aguardar resultado da busca especulativa se ela foi iniciada e shouldSearch é true + let speculativeResults: ScrapedContent[] | null = null; + if (speculativeSearchPromise && preprocessed.shouldSearch) { + chatLog.info('[SpeculativeSearch] Waiting for speculative search results...'); + try { + speculativeResults = await speculativeSearchPromise; + chatLog.info(`[SpeculativeSearch] Got ${speculativeResults.length} speculative results`); + } catch (err) { + chatLog.debug(`[SpeculativeSearch] Failed to get speculative results: ${err}`); + } + } else if (speculativeSearchPromise && !preprocessed.shouldSearch) { + chatLog.info('[SpeculativeSearch] Discarding speculative search (preprocessing determined search not needed)'); + } + + // Executar pesquisa web (usando resultados especulativos como seed se disponíveis) + const { knowledgeBaseContext, scrapedSources, validationReport } = await executeWebResearch( + content, + preprocessed, + speculativeResults + ); + + // Passo 5: Formular resposta + chatLog.info('\nStep 5: Formulating Response'); + // 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) + 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, + }); + + // Removido: setProcessSteps - agora usando mensagens thinking agrupadas + + 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; + }); + } + + // Removido: setThinkingStep - agora usando mensagens thinking agrupadas }; - const toggleSidebar = () => { - const panel = sidebarRef.current; + const handleSelectSession = async (id: string) => { + if (id === currentSessionId) return; + + // Clear any pending saves + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + saveTimeoutRef.current = null; + } + + const history = await loadSessionHistory(id); + setMessages(history); + setCurrentSessionId(id); + lastSavedMessagesRef.current = history.length; + + // Se há busca ativa, encontrar matches e resetar índice + if (searchQuery && searchQuery.trim().length >= 2) { + setSearchMatchIndex(0); + // Os matches serão encontrados quando as mensagens renderizarem + } + }; + + // Handler para scroll infinito reverso (carregar mensagens mais antigas) + const handleScroll = useCallback(async () => { + const container = scrollContainerRef.current; + if (!container || !currentSessionId || paginationState.isLoadingMore) return; + + // Verificar se o usuário está próximo do topo (threshold de 100px) + const isNearTop = container.scrollTop < 100; + + if (isNearTop && paginationState.hasMore) { + // Salvar posição atual do scroll e altura do conteúdo + const scrollHeightBefore = container.scrollHeight; + + // Carregar mais mensagens + const olderMessages = await loadMoreMessages(currentSessionId); + + if (olderMessages.length > 0) { + // Prepend as mensagens antigas + setMessages(prev => [...olderMessages, ...prev]); + + // Restaurar posição do scroll para manter o contexto visual + // (compensar o novo conteúdo adicionado no topo) + requestAnimationFrame(() => { + if (container) { + const scrollHeightAfter = container.scrollHeight; + const heightDiff = scrollHeightAfter - scrollHeightBefore; + container.scrollTop = container.scrollTop + heightDiff; + } + }); + } + } + }, [currentSessionId, paginationState.hasMore, paginationState.isLoadingMore, loadMoreMessages, setMessages]); + + // Função para navegar entre matches + const handleNavigateMatch = useCallback((sessionId: string, direction: 'prev' | 'next') => { + if (sessionId !== currentSessionId || !searchQuery || searchQuery.trim().length < 2) { + return; + } + + // Encontrar todos os matches nas mensagens + const query = searchQuery.trim(); + const matches: Array<{ messageIndex: number; matchIndex: number }> = []; + + messages.forEach((msg, msgIdx) => { + if (msg.role === 'user' || msg.role === 'assistant') { + const content = msg.content; + const regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'); + let match; + let matchIdx = 0; + while ((match = regex.exec(content)) !== null) { + matches.push({ messageIndex: msgIdx, matchIndex: matchIdx }); + matchIdx++; + } + } + }); + + if (matches.length === 0) return; + + // Navegar + setSearchMatchIndex(prev => { + const newIndex = direction === 'next' + ? (prev + 1) % matches.length + : (prev - 1 + matches.length) % matches.length; + + // Scroll para o match após renderização + setTimeout(() => { + const messageElement = document.querySelector(`[data-message-index="${matches[newIndex].messageIndex}"]`); + if (messageElement) { + messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + // Destacar o match atual visualmente + const marks = messageElement.querySelectorAll('mark'); + if (marks.length > matches[newIndex].matchIndex) { + marks[matches[newIndex].matchIndex]?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' }); + } + } + }, 150); + + return newIndex; + }); + }, [currentSessionId, searchQuery, messages]); + + const handleNewChat = () => { + stop(); + clearChat(); + setCurrentSessionId(null); + + // Reset save tracking + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + saveTimeoutRef.current = null; + } + lastSavedMessagesRef.current = 0; + }; + + const toggleChatsSidebar = () => { + const panel = chatsSidebarRef.current; if (panel) { - if (isSidebarCollapsed) { - panel.expand(); + if (isChatsSidebarCollapsed) { + // No mobile, apenas atualizar estado (overlay será mostrado) + if (typeof window !== 'undefined' && window.innerWidth < 768) { + setIsChatsSidebarCollapsed(false); + } else { + 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 ( -
- {/* Left Sidebar (Nav) */} - +
+ {/* Custom Title Bar */} + + + {/* Main Content Area */} +
+ setIsSidebarCollapsed(true)} - onExpand={() => setIsSidebarCollapsed(false)} - className="border-r flex flex-col items-center py-4 gap-4 bg-muted/20 min-w-[60px]" + maxSize={4} + className="border-r flex flex-col items-center py-4 gap-4 bg-muted/20 w-[60px] min-w-[60px] max-w-[60px] flex-shrink-0" > - - + + + {/* Sessions List - Desktop: ResizablePanel, Mobile: Overlay */} + setIsChatsSidebarCollapsed(true)} + onExpand={() => setIsChatsSidebarCollapsed(false)} + className="min-w-0 md:min-w-[220px] md:max-w-[360px] hidden md:block" + style={{ + minWidth: 'clamp(220px, 20vw, 360px)', + maxWidth: '360px', + }} + > + { + handleSelectSession(id); + // Fechar sidebar no mobile após seleção + if (typeof window !== 'undefined' && window.innerWidth < 768) { + setIsChatsSidebarCollapsed(true); + chatsSidebarRef.current?.collapse(); + } + }} + onDeleteSession={deleteSession} + onNewChat={handleNewChat} + onSearch={searchSessions} + isSearching={isSearching} + searchQuery={searchQuery} + onNavigateMatch={handleNavigateMatch} + /> + + + {/* Main Chat Area */} - +
{/* Header */} -
+
- {isSidebarCollapsed && ( - - )} - {!isSidebarCollapsed && ( - - )} -
Chat
-
- -
- -
-
- - {/* Messages */} -
- {messages.length === 0 ? ( -
-
- -
-

Inicie uma conversa com {selectedModel}

-
- ) : ( -
- {messages.map((msg, i) => ( - - ))} - {isLoading && ( -
- Gerando resposta... -
+ )} + +
+ Chat + {isGeneratingTitle && } + {isDownloading && ( + + + {progress} + )}
- )} +
+ +
+
+ + + +
+ +
- {/* Input */} - + {/* Messages Area with Floating Input Layout */} +
+ {/* Scrollable Content Area */} +
+
+ {/* Indicador de carregamento de mensagens antigas */} + {paginationState.isLoadingMore && ( +
+ + Carregando mensagens anteriores... +
+ )} + + {/* Indicador de que há mais mensagens para carregar */} + {paginationState.hasMore && !paginationState.isLoadingMore && messages.length > 0 && ( +
+ + Role para cima para ver mensagens anteriores ({paginationState.totalCount - messages.length} restantes) + +
+ )} + + {messages.length === 0 ? ( +
+
+ +
+
+

Como posso ajudar você hoje?

+

Selecione um modelo e comece uma conversa.

+
+
+ ) : ( +
+ {(() => { + // Agrupar mensagens thinking consecutivas + const groupedMessages: Array<{ + type: 'thinking-group' | 'regular'; + messages?: Message[]; + thinkingSteps?: ThinkingMessageMetadata[]; + searchQueries?: string[]; + index?: number; + message?: Message; + isStreaming?: boolean; + }> = []; + + 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'; + + // Pular mensagens de sistema que não são thinking (system prompt) + // Essas mensagens nunca devem aparecer no chat + const isSystemPrompt = msg.role === 'system' && !isThinkingMessage; + if (isSystemPrompt) { + continue; + } + + 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 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, + }); + } + + // Calcular posições de todos os matches uma vez + const allMatches: Array<{ messageIndex: number; matchIndex: number }> = []; + if (searchQuery && searchQuery.trim().length >= 2) { + const query = searchQuery.trim(); + const regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'); + + messages.forEach((msg, msgIdx) => { + if (msg.role === 'user' || msg.role === 'assistant') { + const content = msg.content; + let match; + let matchIdx = 0; + while ((match = regex.exec(content)) !== null) { + allMatches.push({ messageIndex: msgIdx, matchIndex: matchIdx }); + matchIdx++; + } + } + }); + } + + return groupedMessages.map((group, idx) => { + if (group.type === 'thinking-group' && group.thinkingSteps) { + return ( +
+ +
+ ); + } else if (group.type === 'regular' && group.message) { + // Encontrar índice do match atual para esta mensagem + let messageMatchIndex: number | undefined = undefined; + + if (searchQuery && searchQuery.trim().length >= 2 && allMatches.length > 0 && group.index !== undefined) { + // Encontrar matches desta mensagem + const messageMatches = allMatches.filter(m => m.messageIndex === group.index); + + if (messageMatches.length > 0) { + // Verificar se o match atual está nesta mensagem + const currentMatch = allMatches[searchMatchIndex]; + if (currentMatch && currentMatch.messageIndex === group.index) { + messageMatchIndex = currentMatch.matchIndex; + } + } + } + + return ( +
+ = 2 ? searchQuery.trim() : undefined} + highlightIndex={messageMatchIndex} + messageIndex={group.index} + /> +
+ ); + } + return null; + }); + })()} +
+ )} + + {/* Debug Info (Moved inside scroll) */} + {(deepResearch.state.logs.length > 0 || lastWebContext) && ( +
+
+ {/* Process Log Toggle */} + {deepResearch.state.logs.length > 0 && ( +
+ + {showProcessDebug && ( +
+
+ +
+ {deepResearch.state.logs.map((log, idx) => ( +
+ {log.stage} + {log.input ? (log.input.length > 50 ? log.input.substring(0, 50) + '...' : log.input) : '-'} +
+ ))} +
+ )} +
+ )} + + {/* Context Debug Toggle */} + {lastWebContext && ( +
+ + {showContextDebug && ( +
+
+ {lastContextSources.map((s, i) => ( + {s.title || s.url} + ))} +
+
+

Preview Contexto:

+
{lastWebContext.substring(0, 500)}...
+
+
+ )} +
+ )} +
+
+ )} + + {/* Spacer for Bottom Input */} +
+
+
+ + {/* Fixed Input Container */} +
+ {/* Gradient Overlay */} +
+ + {/* Input Wrapper */} +
+ { + const cat = settings.webSearch.categories.find(c => c.id === id); + if (!cat) return; + settings.updateCategory({ ...cat, enabled }); + }} + /> +
+

OllaHub pode cometer erros. Verifique informações importantes.

+
+
+
+
- - - - {/* Right Sidebar (Settings & Monitor) */} - - -
- - - Parâmetros - - - Sistema - - -
- - -
-
- - -
-