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:
1 parent
8c546f6474
commit
6a7eeaf7af
5 files changed
+588
-11
No files matched your search
+5
-1
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -48,6 +50,29 @@ export function useChatStorage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
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 {
|
||||||
const rawMessages = await invoke<any[]>('load_chat_history', { id });
|
const rawMessages = await invoke<any[]>('load_chat_history', { id });
|
||||||
@@ -375,7 +400,11 @@ export function useChatStorage() {
|
|||||||
deleteSession,
|
deleteSession,
|
||||||
isGeneratingTitle,
|
isGeneratingTitle,
|
||||||
setIsGeneratingTitle,
|
setIsGeneratingTitle,
|
||||||
generateTitleFromUserMessage
|
generateTitleFromUserMessage,
|
||||||
|
searchSessions,
|
||||||
|
searchQuery,
|
||||||
|
setSearchQuery,
|
||||||
|
isSearching
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+239
-3
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user