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

+5 -1
View File
@@ -54,7 +54,9 @@ export default function ChatPage() {
loadSessions, loadSessions,
saveSession, saveSession,
deleteSession, deleteSession,
isGeneratingTitle isGeneratingTitle,
searchSessions,
isSearching
} = useChatStorage(); } = useChatStorage();
const { isDownloading, progress } = useAutoLabelingModel(); const { isDownloading, progress } = useAutoLabelingModel();
@@ -949,6 +951,8 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
}} }}
onDeleteSession={deleteSession} onDeleteSession={deleteSession}
onNewChat={handleNewChat} onNewChat={handleNewChat}
onSearch={searchSessions}
isSearching={isSearching}
/> />
</ResizablePanel> </ResizablePanel>
+79 -6
View File
@@ -1,8 +1,10 @@
import { SessionSummary } from "@/hooks/use-chat-storage"; import { SessionSummary } from "@/hooks/use-chat-storage";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Trash2, Plus, MessageSquare } from "lucide-react"; import { Trash2, Plus, MessageSquare, Search, X, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useState, useEffect, useRef } from "react";
interface SidebarListProps { interface SidebarListProps {
sessions: SessionSummary[]; sessions: SessionSummary[];
@@ -10,6 +12,8 @@ interface SidebarListProps {
onSelectSession: (id: string) => void; onSelectSession: (id: string) => void;
onDeleteSession: (id: string) => void; onDeleteSession: (id: string) => void;
onNewChat: () => void; onNewChat: () => void;
onSearch?: (query: string) => void;
isSearching?: boolean;
} }
function formatDate(dateString: string) { function formatDate(dateString: string) {
@@ -52,13 +56,80 @@ export function SidebarList({
currentSessionId, currentSessionId,
onSelectSession, onSelectSession,
onDeleteSession, onDeleteSession,
onNewChat onNewChat,
onSearch,
isSearching = false
}: SidebarListProps) { }: SidebarListProps) {
const [searchQuery, setSearchQuery] = useState('');
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const DEBOUNCE_MS = 300;
useEffect(() => {
// Limpar timeout anterior
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
// Se query vazia, limpar busca imediatamente
if (!searchQuery.trim()) {
if (onSearch) {
onSearch('');
}
return;
}
// Debounce: aguardar antes de buscar
searchTimeoutRef.current = setTimeout(() => {
if (onSearch && searchQuery.trim().length >= 2) {
onSearch(searchQuery.trim());
}
}, DEBOUNCE_MS);
// Cleanup
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, [searchQuery, onSearch]);
const handleClearSearch = () => {
setSearchQuery('');
if (onSearch) {
onSearch('');
}
};
return ( return (
<TooltipProvider delayDuration={300}> <TooltipProvider delayDuration={300}>
<div className="flex flex-col h-full border-r border-sidebar-border bg-sidebar min-w-[240px] max-w-[360px] w-full"> <div className="flex flex-col h-full border-r border-sidebar-border bg-sidebar min-w-[240px] max-w-[360px] w-full">
{/* Header com botão Nova Conversa */} {/* Header com busca e botão Nova Conversa */}
<div className="p-3 sm:p-4 border-b border-sidebar-border flex-shrink-0"> <div className="p-3 sm:p-4 border-b border-sidebar-border flex-shrink-0 space-y-2">
{/* Campo de busca */}
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
type="text"
placeholder="Buscar conversas..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 pr-8 h-9 text-sm"
/>
{isSearching && (
<Loader2 className="absolute right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground animate-spin" />
)}
{searchQuery && !isSearching && (
<button
onClick={handleClearSearch}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Limpar busca"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Botão Nova Conversa */}
<Button <Button
onClick={onNewChat} onClick={onNewChat}
className="w-full justify-start gap-2 text-sm font-medium h-9 sm:h-10" className="w-full justify-start gap-2 text-sm font-medium h-9 sm:h-10"
@@ -76,10 +147,12 @@ export function SidebarList({
<div className="flex flex-col items-center justify-center py-12 px-4 text-center"> <div className="flex flex-col items-center justify-center py-12 px-4 text-center">
<MessageSquare className="w-10 h-10 text-muted-foreground/40 mb-3" /> <MessageSquare className="w-10 h-10 text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Nenhuma conversa salva {searchQuery ? 'Nenhuma conversa encontrada' : 'Nenhuma conversa salva'}
</p> </p>
<p className="text-xs text-muted-foreground/70 mt-1"> <p className="text-xs text-muted-foreground/70 mt-1">
Comece uma nova conversa para começar {searchQuery
? 'Tente buscar por outro termo'
: 'Comece uma nova conversa para começar'}
</p> </p>
</div> </div>
)} )}
+30 -1
View File
@@ -38,6 +38,8 @@ export function useChatStorage() {
const [sessions, setSessions] = useState<SessionSummary[]>([]); const [sessions, setSessions] = useState<SessionSummary[]>([]);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null); const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [isGeneratingTitle, setIsGeneratingTitle] = useState(false); const [isGeneratingTitle, setIsGeneratingTitle] = useState(false);
const [searchQuery, setSearchQuery] = useState<string>('');
const [isSearching, setIsSearching] = useState(false);
const loadSessions = useCallback(async () => { const loadSessions = useCallback(async () => {
try { try {
@@ -47,6 +49,29 @@ export function useChatStorage() {
console.error("Failed to load sessions:", error); console.error("Failed to load sessions:", error);
} }
}, []); }, []);
const searchSessions = useCallback(async (query: string) => {
if (!query || query.trim().length < 2) {
// Se query muito curta, carregar todas as sessões
await loadSessions();
return;
}
setIsSearching(true);
try {
const results = await invoke<SessionSummary[]>('search_chat_sessions', {
query: query.trim(),
limit: 50
});
setSessions(results);
} catch (error) {
console.error("Failed to search sessions:", error);
// Em caso de erro, carregar todas as sessões
await loadSessions();
} finally {
setIsSearching(false);
}
}, [loadSessions]);
const loadSessionHistory = useCallback(async (id: string): Promise<Message[]> => { const loadSessionHistory = useCallback(async (id: string): Promise<Message[]> => {
try { try {
@@ -375,7 +400,11 @@ export function useChatStorage() {
deleteSession, deleteSession,
isGeneratingTitle, isGeneratingTitle,
setIsGeneratingTitle, setIsGeneratingTitle,
generateTitleFromUserMessage generateTitleFromUserMessage,
searchSessions,
searchQuery,
setSearchQuery,
isSearching
}; };
} }
+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(()) 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 /// Lista todas as sessões ordenadas por updated_at DESC
pub fn list_sessions(&self) -> SqliteResult<Vec<ChatSession>> { pub fn list_sessions(&self) -> SqliteResult<Vec<ChatSession>> {
let mut stmt = self.conn.prepare( let mut stmt = self.conn.prepare(
@@ -304,5 +443,101 @@ impl Database {
} }
Ok(docs) 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(()) 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] #[command]
fn load_chat_sessions(app_handle: AppHandle) -> Result<Vec<SessionSummary>, String> { fn load_chat_sessions(app_handle: AppHandle) -> Result<Vec<SessionSummary>, String> {
let chats_dir = get_chats_dir(&app_handle)?; let chats_dir = get_chats_dir(&app_handle)?;
@@ -453,6 +529,60 @@ fn load_chat_sessions(app_handle: AppHandle) -> Result<Vec<SessionSummary>, Stri
#[command] #[command]
fn load_chat_history(app_handle: AppHandle, id: String) -> Result<Vec<Message>, String> { 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 chats_dir = get_chats_dir(&app_handle)?;
let file_path = chats_dir.join(format!("{}.json", id)); 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()); 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))?; .map_err(|e| format!("Failed to read session file: {}", e))?;
let session: ChatSession = serde_json::from_str(&content) let session: ChatSession = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse session: {}", e))?; .map_err(|e| format!("Failed to parse session: {}", e))?;
log::info!("Loaded {} messages from JSON for session {}", session.messages.len(), id);
Ok(session.messages) Ok(session.messages)
} }
#[command] #[command]
fn delete_chat_session(app_handle: AppHandle, id: String) -> Result<(), String> { 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 chats_dir = get_chats_dir(&app_handle)?;
let file_path = chats_dir.join(format!("{}.json", id)); let file_path = chats_dir.join(format!("{}.json", id));
if file_path.exists() { if file_path.exists() {
fs::remove_file(file_path) if let Err(e) = fs::remove_file(&file_path) {
.map_err(|e| format!("Failed to delete session file: {}", e))?; 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(()) Ok(())
@@ -2047,8 +2219,11 @@ async fn export_chat_sessions(app_handle: AppHandle) -> Result<String, String> {
/// Apaga todo o histórico de conversas /// Apaga todo o histórico de conversas
#[command] #[command]
fn clear_chat_history(app_handle: AppHandle) -> Result<(), String> { fn clear_chat_history(app_handle: AppHandle) -> Result<(), String> {
use db::Database;
let chats_dir = get_chats_dir(&app_handle)?; let chats_dir = get_chats_dir(&app_handle)?;
// 1. Deletar todos os arquivos JSON
let entries = fs::read_dir(&chats_dir) let entries = fs::read_dir(&chats_dir)
.map_err(|e| format!("Failed to read chats dir: {}", e))?; .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); log::info!("Deleted {} chat session files", deleted_count);
Ok(()) 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 /// Retorna o caminho do diretório de dados do app
#[command] #[command]
fn get_app_data_dir(app_handle: AppHandle) -> Result<String, String> { fn get_app_data_dir(app_handle: AppHandle) -> Result<String, String> {
@@ -2983,8 +3217,10 @@ pub fn run() {
delete_model, delete_model,
save_chat_session, save_chat_session,
load_chat_sessions, load_chat_sessions,
search_chat_sessions,
load_chat_history, load_chat_history,
delete_chat_session, delete_chat_session,
cleanup_orphan_sessions,
load_mcp_config, load_mcp_config,
save_mcp_config, save_mcp_config,
get_mcp_config_path_command, get_mcp_config_path_command,