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.
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-27 21:53:27 -03:00
1 parent e53e49c8f8
commit 0d19f496bb
5 files changed
+761 -18

No files matched your search

+143
View File
@@ -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<Vec<ChatMessage>> {
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<ChatMessage>, 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,
+345
View File
@@ -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<db::ChatMessage> = 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<Vec<Message>,
Ok(session.messages)
}
/// Resultado de carregamento paginado de histórico
#[derive(serde::Serialize)]
struct PaginatedHistory {
messages: Vec<Message>,
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<usize>,
offset: Option<usize>,
) -> Result<PaginatedHistory, String> {
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::<ChatSession>(&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<Message> = 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<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 (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::<ChatSession>(&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<Message> = 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::<ChatSession>(&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<Message> = 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,