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.
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-27 17:58:33 -03:00
1 parent 8c546f6474
commit 6a7eeaf7af
5 files changed
+589 -12

No files matched your search

+235
View File
@@ -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<Vec<ChatSession>> {
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<Vec<ChatSession>> {
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)
}
}
+240 -4
View File
@@ -399,6 +399,82 @@ fn save_chat_session(
Ok(())
}
#[command]
fn search_chat_sessions(app_handle: AppHandle, query: String, limit: Option<usize>) -> Result<Vec<SessionSummary>, 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::<String>()
})
})
.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::<ChatSession>(&content) {
return session_data.messages.iter()
.find(|m| m.role == "user" || m.role == "assistant")
.map(|m| m.content.chars().take(50).collect::<String>());
}
}
}
None
})
.unwrap_or_default();
summaries.push(SessionSummary {
id: session.id,
title: session.title,
emoji: session.emoji,
updated_at: session.updated_at, // Já é DateTime<Utc>
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<Vec<SessionSummary>, String> {
let chats_dir = get_chats_dir(&app_handle)?;
@@ -453,6 +529,60 @@ fn load_chat_sessions(app_handle: AppHandle) -> Result<Vec<SessionSummary>, Stri
#[command]
fn load_chat_history(app_handle: AppHandle, id: String) -> Result<Vec<Message>, 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<Message> = 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::<serde_json::Value>(&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<Vec<Message>,
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<String, String> {
/// 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<u32, String> {
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<String, String> {
@@ -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,