feat(chat): enhance search functionality and message highlighting

- Added search match navigation to allow users to cycle through search results in chat sessions.
- Implemented highlighting of search terms in chat messages for better visibility.
- Updated sidebar to display match counts for sessions based on search results.
- Enhanced session search logic in the database to include match counts for improved user experience.
- Refactored chat message component to support dynamic highlighting of search terms.
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-27 20:39:39 -03:00
1 parent 0358a6ea2d
commit 6cc3f71f38
7 files changed
+395 -116

No files matched your search

+115 -61
View File
@@ -22,6 +22,13 @@ pub struct ChatMessage {
pub created_at: DateTime<Utc>,
}
/// Resultado de busca de sessões com contagem de matches
#[derive(Debug, Clone)]
pub struct SearchSessionResult {
pub session: ChatSession,
pub match_count: i64,
}
pub struct Database {
conn: Connection,
}
@@ -446,89 +453,136 @@ impl Database {
/// 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>> {
/// Inclui contagem de matches para navegação
pub fn search_sessions(&self, query: &str, limit: usize) -> SqliteResult<Vec<SearchSessionResult>> {
if query.trim().is_empty() {
// Se query vazia, retornar todas as sessões ordenadas por updated_at
return self.list_sessions();
// Se query vazia, retornar todas as sessões ordenadas por updated_at com match_count = 0
let sessions = self.list_sessions()?;
return Ok(sessions.into_iter().map(|session| SearchSessionResult {
session,
match_count: 0,
}).collect());
}
// 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"
)?;
// Busca simplificada: primeiro buscar por título, depois por conteúdo
// Usando abordagem em duas etapas para evitar problemas com bm25 em CTEs
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?);
// Etapa 1: Buscar sessões por título (FTS5)
let mut title_sessions: Vec<SearchSessionResult> = Vec::new();
{
let mut stmt = self.conn.prepare(
"SELECT s.id, s.title, s.emoji, s.created_at, s.updated_at
FROM sessions s
JOIN sessions_fts ON s.rowid = sessions_fts.rowid
WHERE sessions_fts MATCH ?1
ORDER BY s.updated_at DESC
LIMIT ?2"
)?;
let rows = stmt.query_map(params![&fts_query, limit], |row| {
Ok(SearchSessionResult {
session: 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),
},
match_count: 1, // Match no título conta como 1
})
})?;
for row in rows {
title_sessions.push(row?);
}
}
// Etapa 2: Buscar sessões por conteúdo de mensagens (FTS5)
let mut content_sessions: Vec<(String, i64)> = Vec::new(); // (session_id, match_count)
{
let mut stmt = self.conn.prepare(
"SELECT m.session_id, COUNT(*) as match_count
FROM messages m
JOIN messages_fts ON m.rowid = messages_fts.rowid
WHERE messages_fts MATCH ?1
GROUP BY m.session_id
ORDER BY match_count DESC
LIMIT ?2"
)?;
let rows = stmt.query_map(params![&fts_query, limit], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
for row in rows {
content_sessions.push(row?);
}
}
// Combinar resultados: priorizar matches por título, adicionar matches por conteúdo
let mut session_map: std::collections::HashMap<String, SearchSessionResult> = std::collections::HashMap::new();
// Adicionar sessões encontradas por título
for session in title_sessions {
session_map.insert(session.session.id.clone(), session);
}
// Adicionar ou atualizar com sessões encontradas por conteúdo
for (session_id, match_count) in content_sessions {
if let Some(existing) = session_map.get_mut(&session_id) {
// Atualizar match_count se já existe
existing.match_count = match_count;
} else {
// Buscar dados da sessão
if let Ok(Some(session)) = self.get_session(&session_id) {
session_map.insert(session_id, SearchSessionResult {
session,
match_count,
});
}
}
}
// Converter para vetor e ordenar por updated_at
let mut sessions: Vec<SearchSessionResult> = session_map.into_values().collect();
sessions.sort_by(|a, b| b.session.updated_at.cmp(&a.session.updated_at));
// 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
"SELECT s.id, s.title, s.emoji, s.created_at, s.updated_at,
COUNT(CASE WHEN m.content LIKE ?1 THEN 1 END) as match_count
FROM sessions s
LEFT JOIN messages m ON s.id = m.session_id
WHERE s.title LIKE ?1 OR m.content LIKE ?1
GROUP BY s.id, s.title, s.emoji, s.created_at, s.updated_at
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),
Ok(SearchSessionResult {
session: 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),
},
match_count: row.get(5)?,
})
})?;
+8 -2
View File
@@ -114,6 +114,8 @@ struct SessionSummary {
updated_at: DateTime<Utc>,
preview: String,
platform: String,
#[serde(skip_serializing_if = "Option::is_none")]
match_count: Option<usize>,
}
#[derive(serde::Serialize)]
@@ -407,7 +409,7 @@ fn search_chat_sessions(app_handle: AppHandle, query: String, limit: Option<usiz
.map_err(|e| format!("Failed to open database: {}", e))?;
let search_limit = limit.unwrap_or(50);
let sessions = db.search_sessions(&query, search_limit)
let search_results = db.search_sessions(&query, search_limit)
.map_err(|e| format!("Search failed: {}", e))?;
// Validar existência de cada sessão antes de retornar
@@ -415,7 +417,9 @@ fn search_chat_sessions(app_handle: AppHandle, query: String, limit: Option<usiz
let mut summaries = Vec::new();
let mut orphan_count = 0;
for session in sessions {
for search_result in search_results {
let session = search_result.session;
let match_count = search_result.match_count;
// Verificar se sessão existe no SQLite (já temos)
let exists_in_sqlite = db.get_session(&session.id)
.ok()
@@ -465,6 +469,7 @@ fn search_chat_sessions(app_handle: AppHandle, query: String, limit: Option<usiz
updated_at: session.updated_at, // Já é DateTime<Utc>
preview,
platform: String::new(), // Platform não está no SQLite ainda
match_count: Some(match_count as usize),
});
}
@@ -514,6 +519,7 @@ fn load_chat_sessions(app_handle: AppHandle) -> Result<Vec<SessionSummary>, Stri
updated_at: session.updated_at,
preview: last_msg,
platform: session.platform,
match_count: None,
});
}
}