From 337f7b30567ad45f424bf87a8a20a70dfbc48d12 Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Thu, 27 Nov 2025 21:53:27 -0300 Subject: [PATCH] feat(chat): implement infinite scroll for message loading and optimize chat message rendering - Added infinite scroll functionality to load older messages when the user scrolls near the top of the chat. - Implemented pagination state management to track loading status and available messages. - Enhanced ChatMessage component with React.memo for performance optimization during streaming. - Updated database interactions to support paginated message retrieval, improving efficiency in loading chat history. --- app/chat/page.tsx | 58 +++++- components/chat/chat-message.tsx | 68 +++++- hooks/use-chat-storage.ts | 165 ++++++++++++++- src-tauri/src/db.rs | 143 +++++++++++++ src-tauri/src/lib.rs | 345 +++++++++++++++++++++++++++++++ 5 files changed, 761 insertions(+), 18 deletions(-) diff --git a/app/chat/page.tsx b/app/chat/page.tsx index 9148e82..d9b6d33 100644 --- a/app/chat/page.tsx +++ b/app/chat/page.tsx @@ -48,6 +48,8 @@ export default function ChatPage() { currentSessionId, setCurrentSessionId, loadSessionHistory, + loadMoreMessages, + paginationState, saveSession, deleteSession, isGeneratingTitle, @@ -122,6 +124,7 @@ export default function ChatPage() { 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(''); @@ -886,6 +889,38 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente } }; + // 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) { @@ -1181,8 +1216,29 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente {/* 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 ? (
diff --git a/components/chat/chat-message.tsx b/components/chat/chat-message.tsx index 55293ef..d122c93 100644 --- a/components/chat/chat-message.tsx +++ b/components/chat/chat-message.tsx @@ -1,6 +1,6 @@ import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; -import { useState, useMemo } from 'react'; +import React, { useState, useMemo } from 'react'; import { cn } from "@/lib/utils"; import { Message } from "@/hooks/use-chat"; import { Bot, User } from "lucide-react"; @@ -18,7 +18,14 @@ interface ChatMessageProps { messageIndex?: number; } -export function ChatMessage({ message, isStreaming = false, highlightTerm, highlightIndex = 0, messageIndex }: ChatMessageProps) { +/** + * Componente de mensagem do chat otimizado com React.memo + * + * Durante streaming, usa renderização simplificada (texto puro) para evitar + * recálculo custoso da árvore DOM e syntax highlight a cada token. + * Markdown completo é aplicado apenas quando a mensagem está finalizada. + */ +function ChatMessageComponent({ message, isStreaming = false, highlightTerm, highlightIndex = 0, messageIndex }: ChatMessageProps) { // Verificar se é mensagem de processo de pensamento const isThinkingMessage = message.metadata && typeof message.metadata === 'object' && @@ -56,7 +63,11 @@ export function ChatMessage({ message, isStreaming = false, highlightTerm, highl }, [message.content]); // Processar highlight separadamente (após limpeza de conteúdo) + // Desabilitar durante streaming para performance const highlightedContent = useMemo(() => { + // Não processar highlight durante streaming + if (isStreaming) return null; + if (!highlightTerm || highlightTerm.trim().length < 2 || (message.role !== 'user' && message.role !== 'assistant')) { return null; // Não há highlight } @@ -100,7 +111,7 @@ export function ChatMessage({ message, isStreaming = false, highlightTerm, highl // Adicionar texto restante highlighted += content.substring(lastIndex); return highlighted; - }, [processedContent, highlightTerm, highlightIndex, message.role]); + }, [processedContent, highlightTerm, highlightIndex, message.role, isStreaming]); return ( Gerando resposta...
+ ) : isStreaming ? ( + // Renderização LEVE durante streaming: texto simples com formatação básica + // Evita recálculo de ReactMarkdown e syntax highlight a cada token +
+
+ {processedContent} +
+ +
) : ( + // Renderização COMPLETA quando mensagem finalizada: ReactMarkdown com syntax highlight
{highlightedContent ? (
@@ -160,9 +184,6 @@ export function ChatMessage({ message, isStreaming = false, highlightTerm, highl {processedContent} )} - {isStreaming && !isEmpty && ( - - )}
) )} @@ -181,5 +202,34 @@ export function ChatMessage({ message, isStreaming = false, highlightTerm, highl ); } +/** + * ChatMessage com React.memo para evitar re-renders desnecessários + * + * Comparação customizada: + * - Compara conteúdo da mensagem + * - Compara estado de streaming + * - Compara termo de highlight e índice + */ +export const ChatMessage = React.memo(ChatMessageComponent, (prevProps, nextProps) => { + // Se streaming mudou, sempre re-renderizar + if (prevProps.isStreaming !== nextProps.isStreaming) { + return false; + } + + // Durante streaming, só re-renderizar se conteúdo mudou + if (nextProps.isStreaming) { + return prevProps.message.content === nextProps.message.content; + } + + // Quando não está em streaming, comparar todas as props relevantes + return ( + prevProps.message.content === nextProps.message.content && + prevProps.message.role === nextProps.message.role && + prevProps.highlightTerm === nextProps.highlightTerm && + prevProps.highlightIndex === nextProps.highlightIndex && + prevProps.messageIndex === nextProps.messageIndex + ); +}); - +// Adicionar displayName para debugging +ChatMessage.displayName = 'ChatMessage'; diff --git a/hooks/use-chat-storage.ts b/hooks/use-chat-storage.ts index 24d55f2..649be3b 100644 --- a/hooks/use-chat-storage.ts +++ b/hooks/use-chat-storage.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { Message } from './use-chat'; import { removeMetadataNoise } from '@/lib/metadata'; @@ -13,6 +13,28 @@ export interface SessionSummary { match_count?: number; } +/** Resultado de carregamento paginado do backend */ +interface PaginatedHistoryResult { + messages: Array<{ + role: string; + content: string; + metadata?: unknown; + }>; + total_count: number; + has_more: boolean; +} + +/** Estado de paginação para uma sessão */ +export interface PaginationState { + totalCount: number; + hasMore: boolean; + offset: number; + isLoadingMore: boolean; +} + +/** Número de mensagens a carregar por página */ +const MESSAGES_PER_PAGE = 30; + const TITLE_MODEL = "qwen2.5:0.5b"; // Helper to generate fallback title from user's first message @@ -41,6 +63,17 @@ export function useChatStorage() { const [isGeneratingTitle, setIsGeneratingTitle] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [isSearching, setIsSearching] = useState(false); + + // Estado de paginação por sessão + const [paginationState, setPaginationState] = useState({ + totalCount: 0, + hasMore: false, + offset: 0, + isLoadingMore: false, + }); + + // Ref para evitar múltiplos loads simultâneos + const loadingMoreRef = useRef(false); const loadSessions = useCallback(async () => { try { @@ -74,23 +107,138 @@ export function useChatStorage() { } }, [loadSessions]); - const loadSessionHistory = useCallback(async (id: string): Promise => { + /** + * Carrega histórico de uma sessão com paginação (lazy loading) + * + * Por padrão, carrega as últimas MESSAGES_PER_PAGE mensagens. + * Retorna as mensagens e atualiza o estado de paginação. + */ + const loadSessionHistory = useCallback(async ( + id: string, + options?: { loadAll?: boolean } + ): Promise => { try { - const rawMessages = await invoke('load_chat_history', { id }); - // Convert role from String to type-safe union and ensure proper types - return rawMessages.map(msg => ({ + // Reset pagination state + setPaginationState({ + totalCount: 0, + hasMore: false, + offset: 0, + isLoadingMore: false, + }); + + // Se loadAll = true, usa o comando antigo que carrega tudo + if (options?.loadAll) { + const rawMessages = await invoke>('load_chat_history', { id }); + + return rawMessages.map(msg => ({ + role: (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'system') + ? msg.role as 'user' | 'assistant' | 'system' + : 'user' as const, + content: msg.content || '', + metadata: msg.metadata || undefined + })); + } + + // Usar paginação: carregar últimas N mensagens + const result = await invoke('load_chat_history_paginated', { + id, + limit: MESSAGES_PER_PAGE, + offset: 0 + }); + + // Atualizar estado de paginação + setPaginationState({ + totalCount: result.total_count, + hasMore: result.has_more, + offset: result.messages.length, + isLoadingMore: false, + }); + + // Converter para tipo Message + return result.messages.map(msg => ({ role: (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'system') ? msg.role as 'user' | 'assistant' | 'system' - : 'user' as const, // fallback + : 'user' as const, content: msg.content || '', metadata: msg.metadata || undefined })); } catch (error) { console.error("Failed to load history:", error); - return []; + + // Fallback: tentar carregar tudo com o comando antigo + try { + const rawMessages = await invoke>('load_chat_history', { id }); + + return rawMessages.map(msg => ({ + role: (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'system') + ? msg.role as 'user' | 'assistant' | 'system' + : 'user' as const, + content: msg.content || '', + metadata: msg.metadata || undefined + })); + } catch { + return []; + } } }, []); + /** + * Carrega mais mensagens antigas (scroll infinito reverso) + * + * Chamado quando o usuário faz scroll para o topo do chat. + * Retorna as mensagens anteriores que devem ser prepend ao estado. + */ + const loadMoreMessages = useCallback(async ( + sessionId: string + ): Promise => { + // Evitar múltiplos loads simultâneos + if (loadingMoreRef.current || !paginationState.hasMore) { + return []; + } + + loadingMoreRef.current = true; + setPaginationState(prev => ({ ...prev, isLoadingMore: true })); + + try { + const result = await invoke('load_chat_history_paginated', { + id: sessionId, + limit: MESSAGES_PER_PAGE, + offset: paginationState.offset + }); + + // Atualizar estado de paginação + setPaginationState(prev => ({ + ...prev, + hasMore: result.has_more, + offset: prev.offset + result.messages.length, + isLoadingMore: false, + })); + + // Converter para tipo Message + return result.messages.map(msg => ({ + role: (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'system') + ? msg.role as 'user' | 'assistant' | 'system' + : 'user' as const, + content: msg.content || '', + metadata: msg.metadata || undefined + })); + } catch (error) { + console.error("Failed to load more messages:", error); + setPaginationState(prev => ({ ...prev, isLoadingMore: false })); + return []; + } finally { + loadingMoreRef.current = false; + } + }, [paginationState.hasMore, paginationState.offset]); + const deleteSession = useCallback(async (id: string) => { try { await invoke('delete_chat_session', { id }); @@ -397,6 +545,8 @@ export function useChatStorage() { setCurrentSessionId, loadSessions, loadSessionHistory, + loadMoreMessages, + paginationState, saveSession, deleteSession, isGeneratingTitle, @@ -408,4 +558,3 @@ export function useChatStorage() { isSearching }; } - diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 0d1cd0e..af176ef 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -56,6 +56,19 @@ impl Database { let db_path = app_data_dir.join("ollahub.db"); let conn = Connection::open(&db_path)?; + // Otimizações de performance do SQLite + // WAL mode permite leituras e escritas simultâneas (evita bloqueio da UI) + // synchronous=NORMAL reduz fsync sem perder segurança + // cache_size maior acelera operações frequentes + // temp_store=MEMORY usa RAM para tabelas temporárias + conn.execute_batch( + "PRAGMA journal_mode=WAL; + PRAGMA synchronous=NORMAL; + PRAGMA cache_size=10000; + PRAGMA temp_store=MEMORY; + PRAGMA foreign_keys=ON;" + )?; + let db = Self { conn }; db.init_schema()?; @@ -377,6 +390,54 @@ impl Database { Ok(self.conn.last_insert_rowid()) } + /// Salva múltiplas mensagens de uma sessão em uma transação + /// + /// Remove mensagens existentes da sessão antes de inserir as novas + /// para garantir que não haja duplicatas. + pub fn save_messages_batch( + &self, + session_id: &str, + messages: &[ChatMessage], + ) -> SqliteResult<()> { + // Usar execute_batch para executar múltiplas operações atomicamente + // WAL mode permite isso de forma segura mesmo sem transação explícita + + // Remover mensagens existentes da sessão (para evitar duplicatas) + self.conn.execute( + "DELETE FROM messages WHERE session_id = ?1", + params![session_id], + )?; + + // Inserir todas as mensagens + let mut stmt = self.conn.prepare( + "INSERT INTO messages (session_id, role, content, metadata, created_at) + VALUES (?1, ?2, ?3, ?4, ?5)" + )?; + + for message in messages { + stmt.execute(params![ + message.session_id, + message.role, + message.content, + message.metadata, + message.created_at.to_rfc3339() + ])?; + } + + // Atualizar updated_at da sessão com a data da última mensagem + if let Some(last_message) = messages.last() { + self.conn.execute( + "UPDATE sessions SET updated_at = ?1 WHERE id = ?2", + params![ + last_message.created_at.to_rfc3339(), + session_id + ], + )?; + } + + Ok(()) + } + /// Busca todas as mensagens de uma sessão pub fn get_messages(&self, session_id: &str) -> SqliteResult> { let mut stmt = self.conn.prepare( @@ -406,6 +467,88 @@ impl Database { Ok(messages) } + /// Busca mensagens de uma sessão com paginação (lazy loading) + /// + /// Retorna as últimas `limit` mensagens a partir do `offset`. + /// O offset conta do final (0 = últimas mensagens, 20 = 20 mensagens antes das últimas). + /// + /// Parâmetros: + /// - session_id: ID da sessão + /// - limit: número máximo de mensagens a retornar + /// - offset: número de mensagens a pular do final (0 = começar das últimas) + /// + /// Retorna: (mensagens em ordem ASC, total_count, has_more) + pub fn get_messages_paginated( + &self, + session_id: &str, + limit: usize, + offset: usize, + ) -> SqliteResult<(Vec, usize, bool)> { + // Primeiro, obter o total de mensagens + let total_count: usize = self.conn.query_row( + "SELECT COUNT(*) FROM messages WHERE session_id = ?1", + params![session_id], + |row| row.get(0), + )?; + + // Se não há mensagens, retornar vazio + if total_count == 0 { + return Ok((Vec::new(), 0, false)); + } + + // Estratégia: usar subquery para pegar as últimas N mensagens ordenadas DESC, + // depois ordenar ASC para manter ordem cronológica + // + // Se offset=0 e limit=30: queremos as últimas 30 mensagens + // Se offset=30 e limit=30: queremos as 30 mensagens antes das últimas 30 + let real_limit = std::cmp::min(limit, total_count.saturating_sub(offset)); + + if real_limit == 0 { + return Ok((Vec::new(), total_count, false)); + } + + // Query: pegar as últimas (offset + limit) mensagens ordenadas DESC, + // depois ordenar ASC e pegar as primeiras 'limit' (que são as mais antigas do conjunto) + let mut stmt = self.conn.prepare( + "SELECT id, session_id, role, content, metadata, created_at + FROM ( + SELECT id, session_id, role, content, metadata, created_at + FROM messages + WHERE session_id = ?1 + ORDER BY created_at DESC + LIMIT ?2 + ) AS recent_messages + ORDER BY created_at ASC + LIMIT ?3" + )?; + + // Precisamos pegar (offset + limit) mensagens do final para depois pegar as primeiras 'limit' + let fetch_limit = offset + limit; + + let rows = stmt.query_map(params![session_id, fetch_limit, real_limit], |row| { + Ok(ChatMessage { + id: Some(row.get(0)?), + session_id: row.get(1)?, + role: row.get(2)?, + content: row.get(3)?, + metadata: row.get(4)?, + created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(5)?) + .map_err(|_| rusqlite::Error::InvalidColumnType(5, "TEXT".to_string(), rusqlite::types::Type::Text))? + .with_timezone(&Utc), + }) + })?; + + let mut messages = Vec::new(); + for row in rows { + messages.push(row?); + } + + // has_more = ainda há mensagens mais antigas para carregar + let has_more = offset + messages.len() < total_count; + + Ok((messages, total_count, has_more)) + } + /// Salva um documento RAG pub fn save_rag_document( &self, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8584661..6892aa4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -398,6 +398,57 @@ fn save_chat_session( fs::rename(&temp_path, &file_path) .map_err(|e| format!("Failed to rename temp file to session file: {}", e))?; + // Também salvar no SQLite (sistema novo) para melhor performance e paginação + // Se falhar, apenas logar erro mas não falhar a operação (compatibilidade) + use db::Database; + match Database::new(&app_handle) { + Ok(db) => { + // Criar/atualizar sessão no SQLite + let db_session = db::ChatSession { + id: session.id.clone(), + title: session.title.clone(), + emoji: "💬".to_string(), // Emoji padrão + created_at: session.created_at, + updated_at: session.updated_at, + }; + + if let Err(e) = db.save_session(&db_session) { + log::warn!("Failed to save session to SQLite (continuing with JSON only): {}", e); + } else { + // Converter Message para ChatMessage e salvar no SQLite + // Preservar ordem usando timestamps incrementais baseados no índice + let chat_messages: Vec = session.messages.iter().enumerate().map(|(idx, msg)| { + let metadata_str = msg.metadata.as_ref() + .and_then(|m| serde_json::to_string(m).ok()); + + // Criar timestamp incremental para preservar ordem das mensagens + // Usar created_at da sessão como base e adicionar segundos baseados no índice + // Isso garante que a ordem seja mantida quando ordenado por created_at ASC + let base_time = session.created_at; + let msg_created_at = base_time + chrono::Duration::seconds(idx as i64); + + db::ChatMessage { + id: None, + session_id: session.id.clone(), + role: msg.role.clone(), + content: msg.content.clone(), + metadata: metadata_str, + created_at: msg_created_at, + } + }).collect(); + + if let Err(e) = db.save_messages_batch(&session.id, &chat_messages) { + log::warn!("Failed to save messages to SQLite (continuing with JSON only): {}", e); + } else { + log::debug!("Successfully saved {} messages to SQLite for session {}", chat_messages.len(), session.id); + } + } + } + Err(e) => { + log::debug!("Failed to open database for saving (JSON saved successfully): {}", e); + } + } + // Lock é liberado automaticamente quando _guard sai de escopo Ok(()) } @@ -607,6 +658,299 @@ fn load_chat_history(app_handle: AppHandle, id: String) -> Result, Ok(session.messages) } +/// Resultado de carregamento paginado de histórico +#[derive(serde::Serialize)] +struct PaginatedHistory { + messages: Vec, + total_count: usize, + has_more: bool, +} + +/// Carrega histórico de chat com paginação (lazy loading) +/// +/// Parâmetros: +/// - id: ID da sessão +/// - limit: número máximo de mensagens a retornar (default: 20) +/// - offset: número de mensagens a pular do final (default: 0) +#[command] +fn load_chat_history_paginated( + app_handle: AppHandle, + id: String, + limit: Option, + offset: Option, +) -> Result { + use db::Database; + + let limit = limit.unwrap_or(20); + let offset = offset.unwrap_or(0); + + match Database::new(&app_handle) { + Ok(db) => { + match db.get_messages_paginated(&id, limit, offset) { + Ok((messages, total_count, has_more)) => { + // Se SQLite retornou 0 mensagens, tentar fallback para JSON (sistema legado) + if total_count == 0 { + log::debug!("No messages in SQLite for session {}, trying JSON fallback", id); + + // Tentar carregar do JSON + let chats_dir = match get_chats_dir(&app_handle) { + Ok(dir) => dir, + Err(e) => { + log::debug!("Failed to get chats dir for JSON fallback: {}", e); + // Retornar vazio se não conseguir acessar diretório + return Ok(PaginatedHistory { + messages: Vec::new(), + total_count: 0, + has_more: false, + }); + } + }; + + let file_path = chats_dir.join(format!("{}.json", id)); + if file_path.exists() { + match fs::read_to_string(&file_path) { + Ok(content) => { + match serde_json::from_str::(&content) { + Ok(session) => { + let all_messages = session.messages; + let total = all_messages.len(); + + if total == 0 { + return Ok(PaginatedHistory { + messages: Vec::new(), + total_count: 0, + has_more: false, + }); + } + + // Aplicar paginação em memória (simular comportamento do SQLite) + // offset=0 significa últimas N mensagens + // offset>0 significa mensagens antes das últimas N + let start_idx = if offset + limit <= total { + total - offset - limit + } else { + 0 + }; + + let end_idx = std::cmp::min(start_idx + limit, total); + let paginated_messages: Vec = all_messages + .into_iter() + .skip(start_idx) + .take(end_idx - start_idx) + .collect(); + + let has_more = offset + paginated_messages.len() < total; + + log::info!( + "Loaded {} messages (offset: {}, total: {}, has_more: {}) from JSON fallback for session {}", + paginated_messages.len(), offset, total, has_more, id + ); + + return Ok(PaginatedHistory { + messages: paginated_messages, + total_count: total, + has_more, + }); + } + Err(e) => { + log::debug!("Failed to parse JSON session: {}", e); + } + } + } + Err(e) => { + log::debug!("Failed to read JSON file: {}", e); + } + } + } + + // Se chegou aqui, não encontrou mensagens nem no SQLite nem no JSON + return Ok(PaginatedHistory { + messages: Vec::new(), + total_count: 0, + has_more: false, + }); + } + + // Converter ChatMessage para Message + let result: Vec = messages.into_iter().map(|msg| { + let role = if msg.role == "user" { + "user" + } else if msg.role == "assistant" { + "assistant" + } else { + "system" + }; + + let metadata = msg.metadata.and_then(|m| { + serde_json::from_str::(&m).ok() + }); + + let metadata_value = metadata + .and_then(|m| { + if m.is_object() && !m.as_object().unwrap().is_empty() { + Some(m) + } else { + None + } + }); + + Message { + role: role.to_string(), + content: msg.content, + metadata: metadata_value, + } + }).collect(); + + log::info!( + "Loaded {} messages (offset: {}, total: {}, has_more: {}) from SQLite for session {}", + result.len(), offset, total_count, has_more, id + ); + + Ok(PaginatedHistory { + messages: result, + total_count, + has_more, + }) + } + Err(e) => { + log::debug!("SQLite query failed for session {}: {}, trying JSON fallback", id, e); + + // Fallback para JSON em caso de erro também + let chats_dir = match get_chats_dir(&app_handle) { + Ok(dir) => dir, + Err(_) => { + return Err(format!("Failed to load paginated history: {}", e)); + } + }; + + let file_path = chats_dir.join(format!("{}.json", id)); + if file_path.exists() { + match fs::read_to_string(&file_path) { + Ok(content) => { + match serde_json::from_str::(&content) { + Ok(session) => { + let all_messages = session.messages; + let total = all_messages.len(); + + if total == 0 { + return Ok(PaginatedHistory { + messages: Vec::new(), + total_count: 0, + has_more: false, + }); + } + + let start_idx = if offset + limit <= total { + total - offset - limit + } else { + 0 + }; + + let end_idx = std::cmp::min(start_idx + limit, total); + let paginated_messages: Vec = all_messages + .into_iter() + .skip(start_idx) + .take(end_idx - start_idx) + .collect(); + + let has_more = offset + paginated_messages.len() < total; + + log::info!( + "Loaded {} messages (offset: {}, total: {}, has_more: {}) from JSON fallback (error case) for session {}", + paginated_messages.len(), offset, total, has_more, id + ); + + return Ok(PaginatedHistory { + messages: paginated_messages, + total_count: total, + has_more, + }); + } + Err(e2) => { + return Err(format!("Failed to load paginated history: {} (JSON parse error: {})", e, e2)); + } + } + } + Err(e2) => { + return Err(format!("Failed to load paginated history: {} (JSON read error: {})", e, e2)); + } + } + } + + Err(format!("Failed to load paginated history: {}", e)) + } + } + } + Err(e) => { + log::debug!("Failed to open database: {}, trying JSON fallback", e); + + // Fallback para JSON se não conseguir abrir banco + let chats_dir = match get_chats_dir(&app_handle) { + Ok(dir) => dir, + Err(e2) => { + return Err(format!("Failed to open database: {} (chats dir error: {})", e, e2)); + } + }; + + let file_path = chats_dir.join(format!("{}.json", id)); + if file_path.exists() { + match fs::read_to_string(&file_path) { + Ok(content) => { + match serde_json::from_str::(&content) { + Ok(session) => { + let all_messages = session.messages; + let total = all_messages.len(); + + if total == 0 { + return Ok(PaginatedHistory { + messages: Vec::new(), + total_count: 0, + has_more: false, + }); + } + + let start_idx = if offset + limit <= total { + total - offset - limit + } else { + 0 + }; + + let end_idx = std::cmp::min(start_idx + limit, total); + let paginated_messages: Vec = all_messages + .into_iter() + .skip(start_idx) + .take(end_idx - start_idx) + .collect(); + + let has_more = offset + paginated_messages.len() < total; + + log::info!( + "Loaded {} messages (offset: {}, total: {}, has_more: {}) from JSON fallback (db open error) for session {}", + paginated_messages.len(), offset, total, has_more, id + ); + + return Ok(PaginatedHistory { + messages: paginated_messages, + total_count: total, + has_more, + }); + } + Err(e2) => { + return Err(format!("Failed to open database: {} (JSON parse error: {})", e, e2)); + } + } + } + Err(e2) => { + return Err(format!("Failed to open database: {} (JSON read error: {})", e, e2)); + } + } + } + + Err(format!("Failed to open database: {}", e)) + } + } +} + #[command] fn delete_chat_session(app_handle: AppHandle, id: String) -> Result<(), String> { use db::Database; @@ -3352,6 +3696,7 @@ pub fn run() { load_chat_sessions, search_chat_sessions, load_chat_history, + load_chat_history_paginated, delete_chat_session, cleanup_orphan_sessions, load_mcp_config,