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:
1 parent
0358a6ea2d
commit
6cc3f71f38
7 files changed
+395
-116
No files matched your search
+115
-61
@@ -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)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user