From 6a7eeaf7af96f7d95e911d5f5ce0255b1e584540 Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Thu, 27 Nov 2025 17:58:33 -0300 Subject: [PATCH] feat(chat): implement search functionality for chat sessions - Added search capability to the chat interface, allowing users to find sessions by title or message content. - Integrated debounce logic for search input to enhance performance and user experience. - Updated the sidebar to include a search input field and loading indicator during search operations. - Implemented full-text search in the database for efficient session retrieval based on user queries. - Enhanced session management by filtering out orphan sessions during search results. --- app/chat/page.tsx | 6 +- components/chat/sidebar-list.tsx | 85 ++++++++++- hooks/use-chat-storage.ts | 31 +++- src-tauri/src/db.rs | 235 +++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 244 ++++++++++++++++++++++++++++++- 5 files changed, 589 insertions(+), 12 deletions(-) diff --git a/app/chat/page.tsx b/app/chat/page.tsx index 486161d..c1e5cba 100644 --- a/app/chat/page.tsx +++ b/app/chat/page.tsx @@ -54,7 +54,9 @@ export default function ChatPage() { loadSessions, saveSession, deleteSession, - isGeneratingTitle + isGeneratingTitle, + searchSessions, + isSearching } = useChatStorage(); const { isDownloading, progress } = useAutoLabelingModel(); @@ -949,6 +951,8 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente }} onDeleteSession={deleteSession} onNewChat={handleNewChat} + onSearch={searchSessions} + isSearching={isSearching} /> diff --git a/components/chat/sidebar-list.tsx b/components/chat/sidebar-list.tsx index f3fc98e..96158c5 100644 --- a/components/chat/sidebar-list.tsx +++ b/components/chat/sidebar-list.tsx @@ -1,8 +1,10 @@ import { SessionSummary } from "@/hooks/use-chat-storage"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { Trash2, Plus, MessageSquare } from "lucide-react"; +import { Trash2, Plus, MessageSquare, Search, X, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; +import { useState, useEffect, useRef } from "react"; interface SidebarListProps { sessions: SessionSummary[]; @@ -10,6 +12,8 @@ interface SidebarListProps { onSelectSession: (id: string) => void; onDeleteSession: (id: string) => void; onNewChat: () => void; + onSearch?: (query: string) => void; + isSearching?: boolean; } function formatDate(dateString: string) { @@ -52,13 +56,80 @@ export function SidebarList({ currentSessionId, onSelectSession, onDeleteSession, - onNewChat + onNewChat, + onSearch, + isSearching = false }: SidebarListProps) { + const [searchQuery, setSearchQuery] = useState(''); + const searchTimeoutRef = useRef(null); + const DEBOUNCE_MS = 300; + + useEffect(() => { + // Limpar timeout anterior + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + + // Se query vazia, limpar busca imediatamente + if (!searchQuery.trim()) { + if (onSearch) { + onSearch(''); + } + return; + } + + // Debounce: aguardar antes de buscar + searchTimeoutRef.current = setTimeout(() => { + if (onSearch && searchQuery.trim().length >= 2) { + onSearch(searchQuery.trim()); + } + }, DEBOUNCE_MS); + + // Cleanup + return () => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + }; + }, [searchQuery, onSearch]); + + const handleClearSearch = () => { + setSearchQuery(''); + if (onSearch) { + onSearch(''); + } + }; + return (
- {/* Header com botão Nova Conversa */} -
+ {/* Header com busca e botão Nova Conversa */} +
+ {/* Campo de busca */} +
+ + setSearchQuery(e.target.value)} + className="pl-8 pr-8 h-9 text-sm" + /> + {isSearching && ( + + )} + {searchQuery && !isSearching && ( + + )} +
+ + {/* Botão Nova Conversa */}
)} diff --git a/hooks/use-chat-storage.ts b/hooks/use-chat-storage.ts index 223b95c..8aa8030 100644 --- a/hooks/use-chat-storage.ts +++ b/hooks/use-chat-storage.ts @@ -38,6 +38,8 @@ export function useChatStorage() { const [sessions, setSessions] = useState([]); const [currentSessionId, setCurrentSessionId] = useState(null); const [isGeneratingTitle, setIsGeneratingTitle] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [isSearching, setIsSearching] = useState(false); const loadSessions = useCallback(async () => { try { @@ -47,6 +49,29 @@ export function useChatStorage() { console.error("Failed to load sessions:", error); } }, []); + + const searchSessions = useCallback(async (query: string) => { + if (!query || query.trim().length < 2) { + // Se query muito curta, carregar todas as sessões + await loadSessions(); + return; + } + + setIsSearching(true); + try { + const results = await invoke('search_chat_sessions', { + query: query.trim(), + limit: 50 + }); + setSessions(results); + } catch (error) { + console.error("Failed to search sessions:", error); + // Em caso de erro, carregar todas as sessões + await loadSessions(); + } finally { + setIsSearching(false); + } + }, [loadSessions]); const loadSessionHistory = useCallback(async (id: string): Promise => { try { @@ -375,7 +400,11 @@ export function useChatStorage() { deleteSession, isGeneratingTitle, setIsGeneratingTitle, - generateTitleFromUserMessage + generateTitleFromUserMessage, + searchSessions, + searchQuery, + setSearchQuery, + isSearching }; } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 0f0afaf..bbcc262 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -108,6 +108,140 @@ impl Database { [], )?; + // Índice para ordenação por updated_at + self.conn.execute( + "CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON sessions(updated_at DESC)", + [], + )?; + + // Inicializar FTS (Full-Text Search) + self.init_fts_schema()?; + + Ok(()) + } + + /// Inicializa tabelas FTS5 para busca de texto completo + fn init_fts_schema(&self) -> SqliteResult<()> { + // Tabela FTS para títulos de sessões + self.conn.execute( + "CREATE VIRTUAL TABLE IF NOT EXISTS sessions_fts USING fts5( + id UNINDEXED, + title, + content='sessions', + content_rowid='rowid' + )", + [], + )?; + + // Tabela FTS para conteúdo de mensagens + self.conn.execute( + "CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( + session_id UNINDEXED, + content, + content='messages', + content_rowid='rowid' + )", + [], + )?; + + // Triggers para manter FTS sincronizado com tabelas principais + self.create_fts_triggers()?; + + // Popular tabelas FTS com dados existentes (se necessário) + self.populate_fts_tables()?; + + Ok(()) + } + + /// Cria triggers para manter tabelas FTS sincronizadas + fn create_fts_triggers(&self) -> SqliteResult<()> { + // Trigger para inserir em sessions_fts quando nova sessão é criada + self.conn.execute( + "CREATE TRIGGER IF NOT EXISTS sessions_fts_insert AFTER INSERT ON sessions BEGIN + INSERT INTO sessions_fts(rowid, id, title) VALUES (new.rowid, new.id, new.title); + END", + [], + )?; + + // Trigger para atualizar sessions_fts quando sessão é atualizada + self.conn.execute( + "CREATE TRIGGER IF NOT EXISTS sessions_fts_update AFTER UPDATE ON sessions BEGIN + INSERT INTO sessions_fts(sessions_fts, rowid, id, title) VALUES ('delete', old.rowid, old.id, old.title); + INSERT INTO sessions_fts(rowid, id, title) VALUES (new.rowid, new.id, new.title); + END", + [], + )?; + + // Trigger para deletar de sessions_fts quando sessão é deletada + self.conn.execute( + "CREATE TRIGGER IF NOT EXISTS sessions_fts_delete AFTER DELETE ON sessions BEGIN + INSERT INTO sessions_fts(sessions_fts, rowid, id, title) VALUES ('delete', old.rowid, old.id, old.title); + END", + [], + )?; + + // Trigger para inserir em messages_fts quando nova mensagem é criada + self.conn.execute( + "CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages BEGIN + INSERT INTO messages_fts(rowid, session_id, content) VALUES (new.rowid, new.session_id, new.content); + END", + [], + )?; + + // Trigger para atualizar messages_fts quando mensagem é atualizada + self.conn.execute( + "CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, session_id, content) VALUES ('delete', old.rowid, old.session_id, old.content); + INSERT INTO messages_fts(rowid, session_id, content) VALUES (new.rowid, new.session_id, new.content); + END", + [], + )?; + + // Trigger para deletar de messages_fts quando mensagem é deletada + self.conn.execute( + "CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, session_id, content) VALUES ('delete', old.rowid, old.session_id, old.content); + END", + [], + )?; + + Ok(()) + } + + /// Popula tabelas FTS com dados existentes + fn populate_fts_tables(&self) -> SqliteResult<()> { + // Verificar se sessions_fts já tem dados + let count: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM sessions_fts", + [], + |row| row.get(0), + ).unwrap_or(0); + + // Se vazio, popular com dados existentes + if count == 0 { + self.conn.execute( + "INSERT INTO sessions_fts(rowid, id, title) + SELECT rowid, id, title FROM sessions", + [], + )?; + } + + // Verificar se messages_fts já tem dados + let count: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM messages_fts", + [], + |row| row.get(0), + ).unwrap_or(0); + + // Se vazio, popular com dados existentes + if count == 0 { + self.conn.execute( + "INSERT INTO messages_fts(rowid, session_id, content) + SELECT rowid, session_id, content FROM messages", + [], + )?; + } + Ok(()) } @@ -172,6 +306,11 @@ impl Database { } } + /// Salva uma sessão (create ou update) + pub fn save_session(&self, session: &ChatSession) -> SqliteResult<()> { + self.create_session(session) + } + /// Lista todas as sessões ordenadas por updated_at DESC pub fn list_sessions(&self) -> SqliteResult> { let mut stmt = self.conn.prepare( @@ -304,5 +443,101 @@ impl Database { } Ok(docs) } + + /// Busca sessões por query (título ou conteúdo de mensagens) + /// Retorna resultados ordenados por relevância (match no título > match no conteúdo) + pub fn search_sessions(&self, query: &str, limit: usize) -> SqliteResult> { + if query.trim().is_empty() { + // Se query vazia, retornar todas as sessões ordenadas por updated_at + return self.list_sessions(); + } + + // Escapar caracteres especiais para FTS5 + let escaped_query = query.replace('"', "\"\""); + let fts_query = format!("\"{}\"", escaped_query); + + // Busca combinada: título (peso 1.0) + conteúdo (peso 0.5) + let mut stmt = self.conn.prepare( + "WITH title_matches AS ( + SELECT s.id, s.title, s.emoji, s.created_at, s.updated_at, + bm25(sessions_fts) as rank + FROM sessions s + JOIN sessions_fts ON s.rowid = sessions_fts.rowid + WHERE sessions_fts MATCH ?1 + ORDER BY rank + LIMIT ?2 + ), + content_matches AS ( + SELECT DISTINCT s.id, s.title, s.emoji, s.created_at, s.updated_at, + bm25(messages_fts) * 0.5 as rank + FROM sessions s + JOIN messages m ON s.id = m.session_id + JOIN messages_fts ON m.rowid = messages_fts.rowid + WHERE messages_fts MATCH ?1 + ORDER BY rank + LIMIT ?2 + ) + SELECT id, title, emoji, created_at, updated_at + FROM ( + SELECT * FROM title_matches + UNION + SELECT * FROM content_matches + ) + ORDER BY rank DESC, updated_at DESC + LIMIT ?2" + )?; + + let rows = stmt.query_map(params![fts_query, limit], |row| { + Ok(ChatSession { + id: row.get(0)?, + title: row.get(1)?, + emoji: row.get(2)?, + created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(3)?) + .map_err(|_| rusqlite::Error::InvalidColumnType(3, "TEXT".to_string(), rusqlite::types::Type::Text))? + .with_timezone(&Utc), + updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?) + .map_err(|_| rusqlite::Error::InvalidColumnType(4, "TEXT".to_string(), rusqlite::types::Type::Text))? + .with_timezone(&Utc), + }) + })?; + + let mut sessions = Vec::new(); + for row in rows { + sessions.push(row?); + } + + // Se não encontrou resultados com FTS, tentar busca simples com LIKE (fallback) + if sessions.is_empty() { + let mut stmt = self.conn.prepare( + "SELECT DISTINCT s.id, s.title, s.emoji, s.created_at, s.updated_at + FROM sessions s + LEFT JOIN messages m ON s.id = m.session_id + WHERE s.title LIKE ?1 OR m.content LIKE ?1 + ORDER BY s.updated_at DESC + LIMIT ?2" + )?; + + let like_query = format!("%{}%", query); + let rows = stmt.query_map(params![like_query, limit], |row| { + Ok(ChatSession { + id: row.get(0)?, + title: row.get(1)?, + emoji: row.get(2)?, + created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(3)?) + .map_err(|_| rusqlite::Error::InvalidColumnType(3, "TEXT".to_string(), rusqlite::types::Type::Text))? + .with_timezone(&Utc), + updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?) + .map_err(|_| rusqlite::Error::InvalidColumnType(4, "TEXT".to_string(), rusqlite::types::Type::Text))? + .with_timezone(&Utc), + }) + })?; + + for row in rows { + sessions.push(row?); + } + } + + Ok(sessions) + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8b1a9fb..3bc903a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -399,6 +399,82 @@ fn save_chat_session( Ok(()) } +#[command] +fn search_chat_sessions(app_handle: AppHandle, query: String, limit: Option) -> Result, String> { + use db::Database; + + let db = Database::new(&app_handle) + .map_err(|e| format!("Failed to open database: {}", e))?; + + let search_limit = limit.unwrap_or(50); + let sessions = db.search_sessions(&query, search_limit) + .map_err(|e| format!("Search failed: {}", e))?; + + // Validar existência de cada sessão antes de retornar + let chats_dir = get_chats_dir(&app_handle)?; + let mut summaries = Vec::new(); + let mut orphan_count = 0; + + for session in sessions { + // Verificar se sessão existe no SQLite (já temos) + let exists_in_sqlite = db.get_session(&session.id) + .ok() + .flatten() + .is_some(); + + // Verificar se existe no JSON (sistema legado) para compatibilidade + let json_path = chats_dir.join(format!("{}.json", session.id)); + let exists_in_json = json_path.exists(); + + // Sessão deve existir em pelo menos um sistema + if !exists_in_sqlite && !exists_in_json { + orphan_count += 1; + log::warn!("Found orphan session in search results: {} (title: {})", session.id, session.title); + continue; // Pular sessões órfãs + } + + // Buscar primeira mensagem para preview + let preview = db.get_messages(&session.id) + .ok() + .and_then(|msgs| { + msgs.iter() + .find(|m| m.role == "user" || m.role == "assistant") + .map(|m| { + m.content.chars().take(50).collect::() + }) + }) + .or_else(|| { + // Fallback: tentar ler do JSON se não encontrou no SQLite + if exists_in_json { + if let Ok(content) = fs::read_to_string(&json_path) { + if let Ok(session_data) = serde_json::from_str::(&content) { + return session_data.messages.iter() + .find(|m| m.role == "user" || m.role == "assistant") + .map(|m| m.content.chars().take(50).collect::()); + } + } + } + None + }) + .unwrap_or_default(); + + summaries.push(SessionSummary { + id: session.id, + title: session.title, + emoji: session.emoji, + updated_at: session.updated_at, // Já é DateTime + preview, + platform: String::new(), // Platform não está no SQLite ainda + }); + } + + if orphan_count > 0 { + log::info!("Filtered out {} orphan sessions from search results", orphan_count); + } + + Ok(summaries) +} + #[command] fn load_chat_sessions(app_handle: AppHandle) -> Result, String> { let chats_dir = get_chats_dir(&app_handle)?; @@ -453,6 +529,60 @@ fn load_chat_sessions(app_handle: AppHandle) -> Result, Stri #[command] fn load_chat_history(app_handle: AppHandle, id: String) -> Result, String> { + use db::Database; + + // 1. Tentar carregar do SQLite primeiro (sistema novo) + match Database::new(&app_handle) { + Ok(db) => { + match db.get_messages(&id) { + Ok(messages) if !messages.is_empty() => { + // 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 from SQLite for session {}", result.len(), id); + return Ok(result); + } + Ok(_) => { + // Sessão existe mas não tem mensagens, continuar para fallback + } + Err(e) => { + log::debug!("SQLite query failed for session {}: {}, trying JSON fallback", id, e); + } + } + } + Err(e) => { + log::debug!("Failed to open database: {}, trying JSON fallback", e); + } + } + + // 2. Fallback: tentar carregar do sistema legado (arquivos JSON) let chats_dir = get_chats_dir(&app_handle)?; let file_path = chats_dir.join(format!("{}.json", id)); @@ -460,23 +590,65 @@ fn load_chat_history(app_handle: AppHandle, id: String) -> Result, return Err("Session not found".to_string()); } - let content = fs::read_to_string(file_path) + let content = fs::read_to_string(&file_path) .map_err(|e| format!("Failed to read session file: {}", e))?; let session: ChatSession = serde_json::from_str(&content) .map_err(|e| format!("Failed to parse session: {}", e))?; - + + log::info!("Loaded {} messages from JSON for session {}", session.messages.len(), id); Ok(session.messages) } #[command] fn delete_chat_session(app_handle: AppHandle, id: String) -> Result<(), String> { + use db::Database; + + let mut errors = Vec::new(); + + // 1. Deletar do sistema legado (arquivos JSON) let chats_dir = get_chats_dir(&app_handle)?; let file_path = chats_dir.join(format!("{}.json", id)); if file_path.exists() { - fs::remove_file(file_path) - .map_err(|e| format!("Failed to delete session file: {}", e))?; + if let Err(e) = fs::remove_file(&file_path) { + errors.push(format!("Failed to delete JSON file: {}", e)); + } else { + log::info!("Deleted session JSON file: {}", id); + } + } + + // 2. Deletar do SQLite (sistema novo) + match Database::new(&app_handle) { + Ok(db) => { + if let Err(e) = db.delete_session(&id) { + errors.push(format!("Failed to delete from SQLite: {}", e)); + } else { + log::info!("Deleted session from SQLite: {}", id); + } + } + Err(e) => { + errors.push(format!("Failed to open database: {}", e)); + } + } + + // Se ambos falharam, retornar erro + if !errors.is_empty() && !file_path.exists() { + // Se arquivo JSON não existe, verificar se pelo menos deletou do SQLite + match Database::new(&app_handle) { + Ok(db) => { + if db.get_session(&id).ok().flatten().is_none() { + // Sessão não existe em nenhum lugar, considerar sucesso + return Ok(()); + } + } + _ => {} + } + } + + // Se houve erros mas pelo menos um sistema foi atualizado, logar mas não falhar + if !errors.is_empty() { + log::warn!("Some errors during deletion of session {}: {:?}", id, errors); } Ok(()) @@ -2047,8 +2219,11 @@ async fn export_chat_sessions(app_handle: AppHandle) -> Result { /// Apaga todo o histórico de conversas #[command] fn clear_chat_history(app_handle: AppHandle) -> Result<(), String> { + use db::Database; + let chats_dir = get_chats_dir(&app_handle)?; + // 1. Deletar todos os arquivos JSON let entries = fs::read_dir(&chats_dir) .map_err(|e| format!("Failed to read chats dir: {}", e))?; @@ -2064,10 +2239,69 @@ fn clear_chat_history(app_handle: AppHandle) -> Result<(), String> { } } + // 2. Deletar todas as sessões do SQLite + match Database::new(&app_handle) { + Ok(db) => { + match db.list_sessions() { + Ok(sessions) => { + let mut sqlite_deleted = 0; + for session in sessions { + if let Err(e) = db.delete_session(&session.id) { + log::warn!("Failed to delete session {} from SQLite: {}", session.id, e); + } else { + sqlite_deleted += 1; + } + } + log::info!("Deleted {} sessions from SQLite", sqlite_deleted); + } + Err(e) => { + log::warn!("Failed to list sessions from SQLite: {}", e); + } + } + } + Err(e) => { + log::warn!("Failed to open database: {}", e); + } + } + log::info!("Deleted {} chat session files", deleted_count); Ok(()) } +/// Limpa sessões órfãs do SQLite que não têm arquivo JSON correspondente +#[command] +fn cleanup_orphan_sessions(app_handle: AppHandle) -> Result { + use db::Database; + + let db = Database::new(&app_handle) + .map_err(|e| format!("Failed to open database: {}", e))?; + + let chats_dir = get_chats_dir(&app_handle)?; + let mut orphan_count = 0; + + // Listar todas as sessões do SQLite + let sessions = db.list_sessions() + .map_err(|e| format!("Failed to list sessions: {}", e))?; + + for session in sessions { + let json_path = chats_dir.join(format!("{}.json", session.id)); + + // Se não existe arquivo JSON correspondente, é uma sessão órfã + if !json_path.exists() { + log::info!("Found orphan session: {} (title: {}), removing from SQLite", session.id, session.title); + + if let Err(e) = db.delete_session(&session.id) { + log::warn!("Failed to delete orphan session {}: {}", session.id, e); + } else { + orphan_count += 1; + } + } + } + + log::info!("Cleaned up {} orphan sessions from SQLite", orphan_count); + Ok(orphan_count) +} + /// Retorna o caminho do diretório de dados do app #[command] fn get_app_data_dir(app_handle: AppHandle) -> Result { @@ -2983,8 +3217,10 @@ pub fn run() { delete_model, save_chat_session, load_chat_sessions, + search_chat_sessions, load_chat_history, delete_chat_session, + cleanup_orphan_sessions, load_mcp_config, save_mcp_config, get_mcp_config_path_command,