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:
1 parent
5fb1489eff
commit
337f7b3056
5 files changed
+761
-18
No files matched your search
+57
-1
@@ -48,6 +48,8 @@ export default function ChatPage() {
|
||||
currentSessionId,
|
||||
setCurrentSessionId,
|
||||
loadSessionHistory,
|
||||
loadMoreMessages,
|
||||
paginationState,
|
||||
saveSession,
|
||||
deleteSession,
|
||||
isGeneratingTitle,
|
||||
@@ -122,6 +124,7 @@ export default function ChatPage() {
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastSavedMessagesRef = useRef<number>(0);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [showContextDebug, setShowContextDebug] = useState(false);
|
||||
const [showProcessDebug, setShowProcessDebug] = useState(false);
|
||||
const [lastWebContext, setLastWebContext] = useState('');
|
||||
@@ -886,6 +889,38 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
||||
}
|
||||
};
|
||||
|
||||
// Handler para scroll infinito reverso (carregar mensagens mais antigas)
|
||||
const handleScroll = useCallback(async () => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container || !currentSessionId || paginationState.isLoadingMore) return;
|
||||
|
||||
// Verificar se o usuário está próximo do topo (threshold de 100px)
|
||||
const isNearTop = container.scrollTop < 100;
|
||||
|
||||
if (isNearTop && paginationState.hasMore) {
|
||||
// Salvar posição atual do scroll e altura do conteúdo
|
||||
const scrollHeightBefore = container.scrollHeight;
|
||||
|
||||
// Carregar mais mensagens
|
||||
const olderMessages = await loadMoreMessages(currentSessionId);
|
||||
|
||||
if (olderMessages.length > 0) {
|
||||
// Prepend as mensagens antigas
|
||||
setMessages(prev => [...olderMessages, ...prev]);
|
||||
|
||||
// Restaurar posição do scroll para manter o contexto visual
|
||||
// (compensar o novo conteúdo adicionado no topo)
|
||||
requestAnimationFrame(() => {
|
||||
if (container) {
|
||||
const scrollHeightAfter = container.scrollHeight;
|
||||
const heightDiff = scrollHeightAfter - scrollHeightBefore;
|
||||
container.scrollTop = container.scrollTop + heightDiff;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [currentSessionId, paginationState.hasMore, paginationState.isLoadingMore, loadMoreMessages, setMessages]);
|
||||
|
||||
// Função para navegar entre matches
|
||||
const handleNavigateMatch = useCallback((sessionId: string, direction: 'prev' | 'next') => {
|
||||
if (sessionId !== currentSessionId || !searchQuery || searchQuery.trim().length < 2) {
|
||||
@@ -1181,8 +1216,29 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
||||
{/* Messages Area with Floating Input Layout */}
|
||||
<div className="flex-1 relative h-full overflow-hidden flex flex-col">
|
||||
{/* Scrollable Content Area */}
|
||||
<div className="flex-1 overflow-y-auto scroll-smooth w-full min-h-0">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="flex-1 overflow-y-auto scroll-smooth w-full min-h-0"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div className="max-w-3xl mx-auto w-full px-3 sm:px-4 md:px-6 pt-4 sm:pt-6 pb-4 transition-all duration-300" style={{ paddingLeft: 'clamp(12px, 4vw, 24px)', paddingRight: 'clamp(12px, 4vw, 24px)' }}>
|
||||
{/* Indicador de carregamento de mensagens antigas */}
|
||||
{paginationState.isLoadingMore && (
|
||||
<div className="flex items-center justify-center py-4 mb-4">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground mr-2" />
|
||||
<span className="text-sm text-muted-foreground">Carregando mensagens anteriores...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Indicador de que há mais mensagens para carregar */}
|
||||
{paginationState.hasMore && !paginationState.isLoadingMore && messages.length > 0 && (
|
||||
<div className="flex items-center justify-center py-2 mb-4">
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
Role para cima para ver mensagens anteriores ({paginationState.totalCount - messages.length} restantes)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.length === 0 ? (
|
||||
<div className="min-h-[50vh] flex flex-col items-center justify-center text-muted-foreground space-y-6">
|
||||
<div className="p-6 rounded-2xl bg-muted/30 ring-1 ring-border/50 shadow-sm">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useState, useMemo } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Message } from "@/hooks/use-chat";
|
||||
import { Bot, User } from "lucide-react";
|
||||
@@ -18,7 +18,14 @@ interface ChatMessageProps {
|
||||
messageIndex?: number;
|
||||
}
|
||||
|
||||
export function ChatMessage({ message, isStreaming = false, highlightTerm, highlightIndex = 0, messageIndex }: ChatMessageProps) {
|
||||
/**
|
||||
* Componente de mensagem do chat otimizado com React.memo
|
||||
*
|
||||
* Durante streaming, usa renderização simplificada (texto puro) para evitar
|
||||
* recálculo custoso da árvore DOM e syntax highlight a cada token.
|
||||
* Markdown completo é aplicado apenas quando a mensagem está finalizada.
|
||||
*/
|
||||
function ChatMessageComponent({ message, isStreaming = false, highlightTerm, highlightIndex = 0, messageIndex }: ChatMessageProps) {
|
||||
// Verificar se é mensagem de processo de pensamento
|
||||
const isThinkingMessage = message.metadata &&
|
||||
typeof message.metadata === 'object' &&
|
||||
@@ -56,7 +63,11 @@ export function ChatMessage({ message, isStreaming = false, highlightTerm, highl
|
||||
}, [message.content]);
|
||||
|
||||
// Processar highlight separadamente (após limpeza de conteúdo)
|
||||
// Desabilitar durante streaming para performance
|
||||
const highlightedContent = useMemo(() => {
|
||||
// Não processar highlight durante streaming
|
||||
if (isStreaming) return null;
|
||||
|
||||
if (!highlightTerm || highlightTerm.trim().length < 2 || (message.role !== 'user' && message.role !== 'assistant')) {
|
||||
return null; // Não há highlight
|
||||
}
|
||||
@@ -100,7 +111,7 @@ export function ChatMessage({ message, isStreaming = false, highlightTerm, highl
|
||||
// Adicionar texto restante
|
||||
highlighted += content.substring(lastIndex);
|
||||
return highlighted;
|
||||
}, [processedContent, highlightTerm, highlightIndex, message.role]);
|
||||
}, [processedContent, highlightTerm, highlightIndex, message.role, isStreaming]);
|
||||
|
||||
return (
|
||||
<MessageBackground
|
||||
@@ -141,14 +152,27 @@ export function ChatMessage({ message, isStreaming = false, highlightTerm, highl
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
|
||||
<span className="text-sm font-medium">Gerando resposta...</span>
|
||||
</div>
|
||||
) : isStreaming ? (
|
||||
// Renderização LEVE durante streaming: texto simples com formatação básica
|
||||
// Evita recálculo de ReactMarkdown e syntax highlight a cada token
|
||||
<div className={cn(
|
||||
"prose prose-neutral dark:prose-invert max-w-none break-words leading-7 text-base text-foreground/90",
|
||||
"prose-headings:font-semibold prose-headings:tracking-tight",
|
||||
"relative"
|
||||
)}>
|
||||
<div className="whitespace-pre-wrap font-sans">
|
||||
{processedContent}
|
||||
</div>
|
||||
<span className="inline-block w-2 h-4 bg-primary ml-1 animate-pulse rounded-full align-middle" />
|
||||
</div>
|
||||
) : (
|
||||
// Renderização COMPLETA quando mensagem finalizada: ReactMarkdown com syntax highlight
|
||||
<div className={cn(
|
||||
"prose prose-neutral dark:prose-invert max-w-none break-words leading-7 text-base text-foreground/90",
|
||||
"prose-headings:font-semibold prose-headings:tracking-tight prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg",
|
||||
"prose-p:leading-7 prose-li:leading-7",
|
||||
"prose-pre:bg-muted/50 prose-pre:border prose-pre:border-border/50 prose-pre:rounded-xl",
|
||||
"prose-mark:bg-yellow-200/50 prose-mark:dark:bg-yellow-800/50 prose-mark:px-0.5 prose-mark:rounded",
|
||||
isStreaming && "relative"
|
||||
"prose-mark:bg-yellow-200/50 prose-mark:dark:bg-yellow-800/50 prose-mark:px-0.5 prose-mark:rounded"
|
||||
)}>
|
||||
{highlightedContent ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: highlightedContent }} />
|
||||
@@ -160,9 +184,6 @@ export function ChatMessage({ message, isStreaming = false, highlightTerm, highl
|
||||
{processedContent}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
{isStreaming && !isEmpty && (
|
||||
<span className="inline-block w-2 h-4 bg-primary ml-1 animate-pulse rounded-full align-middle" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
@@ -181,5 +202,34 @@ export function ChatMessage({ message, isStreaming = false, highlightTerm, highl
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ChatMessage com React.memo para evitar re-renders desnecessários
|
||||
*
|
||||
* Comparação customizada:
|
||||
* - Compara conteúdo da mensagem
|
||||
* - Compara estado de streaming
|
||||
* - Compara termo de highlight e índice
|
||||
*/
|
||||
export const ChatMessage = React.memo(ChatMessageComponent, (prevProps, nextProps) => {
|
||||
// Se streaming mudou, sempre re-renderizar
|
||||
if (prevProps.isStreaming !== nextProps.isStreaming) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Durante streaming, só re-renderizar se conteúdo mudou
|
||||
if (nextProps.isStreaming) {
|
||||
return prevProps.message.content === nextProps.message.content;
|
||||
}
|
||||
|
||||
// Quando não está em streaming, comparar todas as props relevantes
|
||||
return (
|
||||
prevProps.message.content === nextProps.message.content &&
|
||||
prevProps.message.role === nextProps.message.role &&
|
||||
prevProps.highlightTerm === nextProps.highlightTerm &&
|
||||
prevProps.highlightIndex === nextProps.highlightIndex &&
|
||||
prevProps.messageIndex === nextProps.messageIndex
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
// Adicionar displayName para debugging
|
||||
ChatMessage.displayName = 'ChatMessage';
|
||||
+157
-8
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Message } from './use-chat';
|
||||
import { removeMetadataNoise } from '@/lib/metadata';
|
||||
@@ -13,6 +13,28 @@ export interface SessionSummary {
|
||||
match_count?: number;
|
||||
}
|
||||
|
||||
/** Resultado de carregamento paginado do backend */
|
||||
interface PaginatedHistoryResult {
|
||||
messages: Array<{
|
||||
role: string;
|
||||
content: string;
|
||||
metadata?: unknown;
|
||||
}>;
|
||||
total_count: number;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
/** Estado de paginação para uma sessão */
|
||||
export interface PaginationState {
|
||||
totalCount: number;
|
||||
hasMore: boolean;
|
||||
offset: number;
|
||||
isLoadingMore: boolean;
|
||||
}
|
||||
|
||||
/** Número de mensagens a carregar por página */
|
||||
const MESSAGES_PER_PAGE = 30;
|
||||
|
||||
const TITLE_MODEL = "qwen2.5:0.5b";
|
||||
|
||||
// Helper to generate fallback title from user's first message
|
||||
@@ -41,6 +63,17 @@ export function useChatStorage() {
|
||||
const [isGeneratingTitle, setIsGeneratingTitle] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
// Estado de paginação por sessão
|
||||
const [paginationState, setPaginationState] = useState<PaginationState>({
|
||||
totalCount: 0,
|
||||
hasMore: false,
|
||||
offset: 0,
|
||||
isLoadingMore: false,
|
||||
});
|
||||
|
||||
// Ref para evitar múltiplos loads simultâneos
|
||||
const loadingMoreRef = useRef(false);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
try {
|
||||
@@ -74,23 +107,138 @@ export function useChatStorage() {
|
||||
}
|
||||
}, [loadSessions]);
|
||||
|
||||
const loadSessionHistory = useCallback(async (id: string): Promise<Message[]> => {
|
||||
/**
|
||||
* Carrega histórico de uma sessão com paginação (lazy loading)
|
||||
*
|
||||
* Por padrão, carrega as últimas MESSAGES_PER_PAGE mensagens.
|
||||
* Retorna as mensagens e atualiza o estado de paginação.
|
||||
*/
|
||||
const loadSessionHistory = useCallback(async (
|
||||
id: string,
|
||||
options?: { loadAll?: boolean }
|
||||
): Promise<Message[]> => {
|
||||
try {
|
||||
const rawMessages = await invoke<any[]>('load_chat_history', { id });
|
||||
// Convert role from String to type-safe union and ensure proper types
|
||||
return rawMessages.map(msg => ({
|
||||
// Reset pagination state
|
||||
setPaginationState({
|
||||
totalCount: 0,
|
||||
hasMore: false,
|
||||
offset: 0,
|
||||
isLoadingMore: false,
|
||||
});
|
||||
|
||||
// Se loadAll = true, usa o comando antigo que carrega tudo
|
||||
if (options?.loadAll) {
|
||||
const rawMessages = await invoke<Array<{
|
||||
role: string;
|
||||
content: string;
|
||||
metadata?: unknown;
|
||||
}>>('load_chat_history', { id });
|
||||
|
||||
return rawMessages.map(msg => ({
|
||||
role: (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'system')
|
||||
? msg.role as 'user' | 'assistant' | 'system'
|
||||
: 'user' as const,
|
||||
content: msg.content || '',
|
||||
metadata: msg.metadata || undefined
|
||||
}));
|
||||
}
|
||||
|
||||
// Usar paginação: carregar últimas N mensagens
|
||||
const result = await invoke<PaginatedHistoryResult>('load_chat_history_paginated', {
|
||||
id,
|
||||
limit: MESSAGES_PER_PAGE,
|
||||
offset: 0
|
||||
});
|
||||
|
||||
// Atualizar estado de paginação
|
||||
setPaginationState({
|
||||
totalCount: result.total_count,
|
||||
hasMore: result.has_more,
|
||||
offset: result.messages.length,
|
||||
isLoadingMore: false,
|
||||
});
|
||||
|
||||
// Converter para tipo Message
|
||||
return result.messages.map(msg => ({
|
||||
role: (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'system')
|
||||
? msg.role as 'user' | 'assistant' | 'system'
|
||||
: 'user' as const, // fallback
|
||||
: 'user' as const,
|
||||
content: msg.content || '',
|
||||
metadata: msg.metadata || undefined
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Failed to load history:", error);
|
||||
return [];
|
||||
|
||||
// Fallback: tentar carregar tudo com o comando antigo
|
||||
try {
|
||||
const rawMessages = await invoke<Array<{
|
||||
role: string;
|
||||
content: string;
|
||||
metadata?: unknown;
|
||||
}>>('load_chat_history', { id });
|
||||
|
||||
return rawMessages.map(msg => ({
|
||||
role: (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'system')
|
||||
? msg.role as 'user' | 'assistant' | 'system'
|
||||
: 'user' as const,
|
||||
content: msg.content || '',
|
||||
metadata: msg.metadata || undefined
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Carrega mais mensagens antigas (scroll infinito reverso)
|
||||
*
|
||||
* Chamado quando o usuário faz scroll para o topo do chat.
|
||||
* Retorna as mensagens anteriores que devem ser prepend ao estado.
|
||||
*/
|
||||
const loadMoreMessages = useCallback(async (
|
||||
sessionId: string
|
||||
): Promise<Message[]> => {
|
||||
// Evitar múltiplos loads simultâneos
|
||||
if (loadingMoreRef.current || !paginationState.hasMore) {
|
||||
return [];
|
||||
}
|
||||
|
||||
loadingMoreRef.current = true;
|
||||
setPaginationState(prev => ({ ...prev, isLoadingMore: true }));
|
||||
|
||||
try {
|
||||
const result = await invoke<PaginatedHistoryResult>('load_chat_history_paginated', {
|
||||
id: sessionId,
|
||||
limit: MESSAGES_PER_PAGE,
|
||||
offset: paginationState.offset
|
||||
});
|
||||
|
||||
// Atualizar estado de paginação
|
||||
setPaginationState(prev => ({
|
||||
...prev,
|
||||
hasMore: result.has_more,
|
||||
offset: prev.offset + result.messages.length,
|
||||
isLoadingMore: false,
|
||||
}));
|
||||
|
||||
// Converter para tipo Message
|
||||
return result.messages.map(msg => ({
|
||||
role: (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'system')
|
||||
? msg.role as 'user' | 'assistant' | 'system'
|
||||
: 'user' as const,
|
||||
content: msg.content || '',
|
||||
metadata: msg.metadata || undefined
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Failed to load more messages:", error);
|
||||
setPaginationState(prev => ({ ...prev, isLoadingMore: false }));
|
||||
return [];
|
||||
} finally {
|
||||
loadingMoreRef.current = false;
|
||||
}
|
||||
}, [paginationState.hasMore, paginationState.offset]);
|
||||
|
||||
const deleteSession = useCallback(async (id: string) => {
|
||||
try {
|
||||
await invoke('delete_chat_session', { id });
|
||||
@@ -397,6 +545,8 @@ export function useChatStorage() {
|
||||
setCurrentSessionId,
|
||||
loadSessions,
|
||||
loadSessionHistory,
|
||||
loadMoreMessages,
|
||||
paginationState,
|
||||
saveSession,
|
||||
deleteSession,
|
||||
isGeneratingTitle,
|
||||
@@ -408,4 +558,3 @@ export function useChatStorage() {
|
||||
isSearching
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user