feat(chat): enhance search functionality and message highlighting

- Added search match navigation to allow users to cycle through search results in chat sessions.
- Implemented highlighting of search terms in chat messages for better visibility.
- Updated sidebar to display match counts for sessions based on search results.
- Enhanced session search logic in the database to include match counts for improved user experience.
- Refactored chat message component to support dynamic highlighting of search terms.
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-27 20:39:39 -03:00
1 parent 0358a6ea2d
commit 6cc3f71f38
7 files changed
+395 -116

No files matched your search

+105 -2
View File
@@ -52,7 +52,8 @@ export default function ChatPage() {
deleteSession,
isGeneratingTitle,
searchSessions,
isSearching
isSearching,
searchQuery
} = useChatStorage();
const { isDownloading, progress } = useAutoLabelingModel();
@@ -128,6 +129,14 @@ export default function ChatPage() {
const [showDownloadDialog, setShowDownloadDialog] = useState(false);
const [logsCopied, setLogsCopied] = useState(false);
const [lastUserQuery, setLastUserQuery] = useState('');
const [searchMatchIndex, setSearchMatchIndex] = useState(0);
// Resetar índice quando busca é limpa ou sessão muda
useEffect(() => {
if (!searchQuery || searchQuery.trim().length < 2) {
setSearchMatchIndex(0);
}
}, [searchQuery, currentSessionId]);
// Auto-select first model
useEffect(() => {
@@ -803,7 +812,61 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
setMessages(history);
setCurrentSessionId(id);
lastSavedMessagesRef.current = history.length;
// Se há busca ativa, encontrar matches e resetar índice
if (searchQuery && searchQuery.trim().length >= 2) {
setSearchMatchIndex(0);
// Os matches serão encontrados quando as mensagens renderizarem
}
};
// Função para navegar entre matches
const handleNavigateMatch = useCallback((sessionId: string, direction: 'prev' | 'next') => {
if (sessionId !== currentSessionId || !searchQuery || searchQuery.trim().length < 2) {
return;
}
// Encontrar todos os matches nas mensagens
const query = searchQuery.trim();
const matches: Array<{ messageIndex: number; matchIndex: number }> = [];
messages.forEach((msg, msgIdx) => {
if (msg.role === 'user' || msg.role === 'assistant') {
const content = msg.content;
const regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
let match;
let matchIdx = 0;
while ((match = regex.exec(content)) !== null) {
matches.push({ messageIndex: msgIdx, matchIndex: matchIdx });
matchIdx++;
}
}
});
if (matches.length === 0) return;
// Navegar
setSearchMatchIndex(prev => {
const newIndex = direction === 'next'
? (prev + 1) % matches.length
: (prev - 1 + matches.length) % matches.length;
// Scroll para o match após renderização
setTimeout(() => {
const messageElement = document.querySelector(`[data-message-index="${matches[newIndex].messageIndex}"]`);
if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Destacar o match atual visualmente
const marks = messageElement.querySelectorAll('mark');
if (marks.length > matches[newIndex].matchIndex) {
marks[matches[newIndex].matchIndex]?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
}
}
}, 150);
return newIndex;
});
}, [currentSessionId, searchQuery, messages]);
const handleNewChat = () => {
stop();
@@ -947,6 +1010,8 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
onNewChat={handleNewChat}
onSearch={searchSessions}
isSearching={isSearching}
searchQuery={searchQuery}
onNavigateMatch={handleNavigateMatch}
/>
</ResizablePanel>
@@ -1132,6 +1197,25 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
});
}
// Calcular posições de todos os matches uma vez
const allMatches: Array<{ messageIndex: number; matchIndex: number }> = [];
if (searchQuery && searchQuery.trim().length >= 2) {
const query = searchQuery.trim();
const regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
messages.forEach((msg, msgIdx) => {
if (msg.role === 'user' || msg.role === 'assistant') {
const content = msg.content;
let match;
let matchIdx = 0;
while ((match = regex.exec(content)) !== null) {
allMatches.push({ messageIndex: msgIdx, matchIndex: matchIdx });
matchIdx++;
}
}
});
}
return groupedMessages.map((group, idx) => {
if (group.type === 'thinking-group' && group.thinkingSteps) {
return (
@@ -1143,11 +1227,30 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
</div>
);
} else if (group.type === 'regular' && group.message) {
// Encontrar índice do match atual para esta mensagem
let messageMatchIndex: number | undefined = undefined;
if (searchQuery && searchQuery.trim().length >= 2 && allMatches.length > 0 && group.index !== undefined) {
// Encontrar matches desta mensagem
const messageMatches = allMatches.filter(m => m.messageIndex === group.index);
if (messageMatches.length > 0) {
// Verificar se o match atual está nesta mensagem
const currentMatch = allMatches[searchMatchIndex];
if (currentMatch && currentMatch.messageIndex === group.index) {
messageMatchIndex = currentMatch.matchIndex;
}
}
}
return (
<div key={group.index || idx} className="animate-in fade-in slide-in-from-bottom-4 duration-500">
<ChatMessage
message={group.message}
isStreaming={group.isStreaming}
isStreaming={group.isStreaming}
highlightTerm={searchQuery && searchQuery.trim().length >= 2 ? searchQuery.trim() : undefined}
highlightIndex={messageMatchIndex}
messageIndex={group.index}
/>
</div>
);
+68 -10
View File
@@ -13,9 +13,12 @@ import type { ThinkingMessageMetadata } from "@/hooks/use-chat";
interface ChatMessageProps {
message: Message;
isStreaming?: boolean;
highlightTerm?: string;
highlightIndex?: number;
messageIndex?: number;
}
export function ChatMessage({ message, isStreaming = false }: ChatMessageProps) {
export function ChatMessage({ message, isStreaming = false, highlightTerm, highlightIndex = 0, messageIndex }: ChatMessageProps) {
// Verificar se é mensagem de processo de pensamento
const isThinkingMessage = message.metadata &&
typeof message.metadata === 'object' &&
@@ -51,6 +54,53 @@ export function ChatMessage({ message, isStreaming = false }: ChatMessageProps)
return content || message.content;
}, [message.content]);
// Processar highlight separadamente (após limpeza de conteúdo)
const highlightedContent = useMemo(() => {
if (!highlightTerm || highlightTerm.trim().length < 2 || (message.role !== 'user' && message.role !== 'assistant')) {
return null; // Não há highlight
}
const term = highlightTerm.trim();
const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${escapedTerm})`, 'gi');
const content = processedContent;
// Encontrar todos os matches
const matches: Array<{ start: number; end: number; text: string }> = [];
let match;
while ((match = regex.exec(content)) !== null) {
matches.push({
start: match.index,
end: match.index + match[0].length,
text: match[0]
});
}
if (matches.length === 0) return null;
// Construir conteúdo com highlights
let highlighted = '';
let lastIndex = 0;
matches.forEach((match, idx) => {
// Adicionar texto antes do match
highlighted += content.substring(lastIndex, match.start);
// Adicionar match com highlight (laranja para atual, amarelo para outros)
const isCurrent = highlightIndex !== undefined && idx === highlightIndex;
const className = isCurrent
? 'bg-orange-400/60 dark:bg-orange-500/60 px-0.5 rounded'
: 'bg-yellow-200/50 dark:bg-yellow-800/50 px-0.5 rounded';
highlighted += `<mark class="${className}">${match.text}</mark>`;
lastIndex = match.end;
});
// Adicionar texto restante
highlighted += content.substring(lastIndex);
return highlighted;
}, [processedContent, highlightTerm, highlightIndex, message.role]);
return (
<MessageBackground
@@ -75,11 +125,14 @@ export function ChatMessage({ message, isStreaming = false }: ChatMessageProps)
</div>
</div>
<div className="flex-1 overflow-hidden min-w-0 pt-1">
<div className="flex-1 overflow-hidden min-w-0 pt-1" data-message-index={messageIndex}>
{isUser ? (
// Estilo específico para mensagem do usuário (mais destaque, fonte maior)
<div className="text-lg sm:text-xl font-medium tracking-tight text-foreground/90 leading-relaxed break-words whitespace-pre-wrap">
{processedContent}
<div
className="text-lg sm:text-xl font-medium tracking-tight text-foreground/90 leading-relaxed break-words whitespace-pre-wrap"
dangerouslySetInnerHTML={highlightedContent ? { __html: highlightedContent } : undefined}
>
{!highlightedContent && processedContent}
</div>
) : (
// Estilo Assistant
@@ -94,14 +147,19 @@ export function ChatMessage({ message, isStreaming = false }: ChatMessageProps)
"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"
)}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownRenderers}
>
{processedContent}
</ReactMarkdown>
{highlightedContent ? (
<div dangerouslySetInnerHTML={{ __html: highlightedContent }} />
) : (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownRenderers}
>
{processedContent}
</ReactMarkdown>
)}
{isStreaming && !isEmpty && (
<span className="inline-block w-2 h-4 bg-primary ml-1 animate-pulse rounded-full align-middle" />
)}
+58 -15
View File
@@ -2,7 +2,7 @@ import { SessionSummary } from "@/hooks/use-chat-storage";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Trash2, Plus, MessageSquare, Search, X, Loader2 } from "lucide-react";
import { Trash2, Plus, MessageSquare, Search, X, Loader2, ChevronUp, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { useState, useEffect, useRef } from "react";
@@ -14,6 +14,8 @@ interface SidebarListProps {
onNewChat: () => void;
onSearch?: (query: string) => void;
isSearching?: boolean;
searchQuery?: string;
onNavigateMatch?: (sessionId: string, direction: 'prev' | 'next') => void;
}
function formatDate(dateString: string) {
@@ -58,9 +60,11 @@ export function SidebarList({
onDeleteSession,
onNewChat,
onSearch,
isSearching = false
isSearching = false,
searchQuery = '',
onNavigateMatch
}: SidebarListProps) {
const [searchQuery, setSearchQuery] = useState('');
const [localSearchInput, setLocalSearchInput] = useState('');
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const DEBOUNCE_MS = 300;
@@ -71,7 +75,7 @@ export function SidebarList({
}
// Se query vazia, limpar busca imediatamente
if (!searchQuery.trim()) {
if (!localSearchInput.trim()) {
if (onSearch) {
onSearch('');
}
@@ -80,8 +84,8 @@ export function SidebarList({
// Debounce: aguardar antes de buscar
searchTimeoutRef.current = setTimeout(() => {
if (onSearch && searchQuery.trim().length >= 2) {
onSearch(searchQuery.trim());
if (onSearch && localSearchInput.trim().length >= 2) {
onSearch(localSearchInput.trim());
}
}, DEBOUNCE_MS);
@@ -91,10 +95,10 @@ export function SidebarList({
clearTimeout(searchTimeoutRef.current);
}
};
}, [searchQuery, onSearch]);
}, [localSearchInput, onSearch]);
const handleClearSearch = () => {
setSearchQuery('');
setLocalSearchInput('');
if (onSearch) {
onSearch('');
}
@@ -111,14 +115,14 @@ export function SidebarList({
<Input
type="text"
placeholder="Buscar conversas..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
value={localSearchInput}
onChange={(e) => setLocalSearchInput(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 && (
{localSearchInput && !isSearching && (
<button
onClick={handleClearSearch}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
@@ -190,16 +194,55 @@ export function SidebarList({
</div>
{/* Texto do card */}
<div className="sidebar-chat-item-text">
<div className="sidebar-chat-item-text flex-1 min-w-0">
<h3 className="sidebar-chat-item-title text-sm font-medium text-card-foreground leading-tight mb-0.5">
{session.title}
</h3>
<p className="sidebar-chat-item-date text-xs text-muted-foreground">
{formatDate(session.updated_at)}
</p>
<div className="flex items-center gap-2">
<p className="sidebar-chat-item-date text-xs text-muted-foreground">
{formatDate(session.updated_at)}
</p>
{searchQuery && session.match_count !== undefined && session.match_count > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
{session.match_count} {session.match_count === 1 ? 'ocorrência' : 'ocorrências'}
</span>
)}
</div>
</div>
{/* Navegação de matches - aparece apenas quando selecionado e há busca ativa */}
{isSelected && searchQuery && session.match_count !== undefined && session.match_count > 0 && onNavigateMatch && (
<div className="flex flex-col gap-0.5 mr-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onNavigateMatch(session.id, 'prev');
}}
aria-label="Ocorrência anterior"
>
<ChevronUp className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onNavigateMatch(session.id, 'next');
}}
aria-label="Próxima ocorrência"
>
<ChevronDown className="w-3.5 h-3.5" />
</Button>
</div>
)}
{/* Botão de deletar - aparece no hover */}
<Button
variant="ghost"
+1
View File
@@ -10,6 +10,7 @@ export interface SessionSummary {
updated_at: string;
preview: string;
platform: string;
match_count?: number;
}
const TITLE_MODEL = "qwen2.5:0.5b";
+40 -26
View File
@@ -240,6 +240,29 @@ export function useChat() {
const rawContentRef = useRef<string>(''); // Track raw content including metadata during streaming
const currentSessionIdRef = useRef<string | null>(null);
const lastTokenRef = useRef<string>(''); // Track last token to detect duplicates
const renderScheduledRef = useRef<boolean>(false);
const rafIdRef = useRef<number | null>(null);
// Função para atualizar o estado visual com o conteúdo acumulado
const flushToState = () => {
renderScheduledRef.current = false;
const displayContent = stripMetadataTag(rawContentRef.current);
setMessages(prev => {
const last = prev[prev.length - 1];
if (last && last.role === 'assistant') {
return [...prev.slice(0, -1), { ...last, content: displayContent }];
}
return [...prev, { role: 'assistant', content: displayContent }];
});
};
// Agendar flush com requestAnimationFrame (limita a ~60fps)
const scheduleFlush = () => {
if (!renderScheduledRef.current) {
renderScheduledRef.current = true;
rafIdRef.current = requestAnimationFrame(flushToState);
}
};
// Listener para eventos do Rust
useEffect(() => {
@@ -262,51 +285,38 @@ export function useChat() {
const { content, done } = event.payload;
if (content && content.length > 0) {
// Verificar se este é o mesmo token do último processado (evitar duplicação)
// Isso pode acontecer se o evento for emitido múltiplas vezes
// Detecção de duplicatas (MANTER LÓGICA ORIGINAL)
if (content === lastTokenRef.current && rawContentRef.current.endsWith(content)) {
// Token duplicado, ignorar
if (process.env.NODE_ENV === 'development') {
console.warn('Token duplicado detectado, ignorando:', content.substring(0, 30));
}
return;
}
// Atualizar referência do último token
lastTokenRef.current = content;
// Accumulate raw content (including metadata) in ref
rawContentRef.current += content;
setMessages(prev => {
const last = prev[prev.length - 1];
if (last && last.role === 'assistant') {
// For display: strip incomplete metadata tag if present
const displayContent = stripMetadataTag(rawContentRef.current);
return [
...prev.slice(0, -1),
{ ...last, content: displayContent }
];
}
// Se não existe mensagem do assistente, criar uma
return [...prev, { role: 'assistant', content: stripMetadataTag(rawContentRef.current) }];
});
// MUDANÇA: Agendar flush em vez de chamar setMessages diretamente
scheduleFlush();
}
if (done) {
// Stream terminou, processar metadata final
// Cancelar qualquer flush pendente
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
renderScheduledRef.current = false;
}
// Flush IMEDIATO com processamento de metadata
setMessages(prev => {
const last = prev[prev.length - 1];
if (last && last.role === 'assistant') {
const rawContent = rawContentRef.current;
const { content: finalContent, metadata } = parseMetadata(rawContent);
rawContentRef.current = '';
lastTokenRef.current = ''; // Reset last token tracker
return [
...prev.slice(0, -1),
{ ...last, content: finalContent || last.content, metadata }
];
lastTokenRef.current = '';
return [...prev.slice(0, -1), { ...last, content: finalContent || last.content, metadata }];
}
return prev;
});
@@ -331,6 +341,10 @@ export function useChat() {
unlistenCreated?.();
unlistenToken?.();
unlistenError?.();
// Cancelar RAF pendente
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
}
};
}, []);
+115 -61
View File
@@ -22,6 +22,13 @@ pub struct ChatMessage {
pub created_at: DateTime<Utc>,
}
/// Resultado de busca de sessões com contagem de matches
#[derive(Debug, Clone)]
pub struct SearchSessionResult {
pub session: ChatSession,
pub match_count: i64,
}
pub struct Database {
conn: Connection,
}
@@ -446,89 +453,136 @@ impl Database {
/// Busca sessões por query (título ou conteúdo de mensagens)
/// Retorna resultados ordenados por relevância (match no título > match no conteúdo)
pub fn search_sessions(&self, query: &str, limit: usize) -> SqliteResult<Vec<ChatSession>> {
/// Inclui contagem de matches para navegação
pub fn search_sessions(&self, query: &str, limit: usize) -> SqliteResult<Vec<SearchSessionResult>> {
if query.trim().is_empty() {
// Se query vazia, retornar todas as sessões ordenadas por updated_at
return self.list_sessions();
// Se query vazia, retornar todas as sessões ordenadas por updated_at com match_count = 0
let sessions = self.list_sessions()?;
return Ok(sessions.into_iter().map(|session| SearchSessionResult {
session,
match_count: 0,
}).collect());
}
// Escapar caracteres especiais para FTS5
let escaped_query = query.replace('"', "\"\"");
let fts_query = format!("\"{}\"", escaped_query);
// Busca combinada: título (peso 1.0) + conteúdo (peso 0.5)
let mut stmt = self.conn.prepare(
"WITH title_matches AS (
SELECT s.id, s.title, s.emoji, s.created_at, s.updated_at,
bm25(sessions_fts) as rank
FROM sessions s
JOIN sessions_fts ON s.rowid = sessions_fts.rowid
WHERE sessions_fts MATCH ?1
ORDER BY rank
LIMIT ?2
),
content_matches AS (
SELECT DISTINCT s.id, s.title, s.emoji, s.created_at, s.updated_at,
bm25(messages_fts) * 0.5 as rank
FROM sessions s
JOIN messages m ON s.id = m.session_id
JOIN messages_fts ON m.rowid = messages_fts.rowid
WHERE messages_fts MATCH ?1
ORDER BY rank
LIMIT ?2
)
SELECT id, title, emoji, created_at, updated_at
FROM (
SELECT * FROM title_matches
UNION
SELECT * FROM content_matches
)
ORDER BY rank DESC, updated_at DESC
LIMIT ?2"
)?;
// Busca simplificada: primeiro buscar por título, depois por conteúdo
// Usando abordagem em duas etapas para evitar problemas com bm25 em CTEs
let rows = stmt.query_map(params![fts_query, limit], |row| {
Ok(ChatSession {
id: row.get(0)?,
title: row.get(1)?,
emoji: row.get(2)?,
created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(3)?)
.map_err(|_| rusqlite::Error::InvalidColumnType(3, "TEXT".to_string(), rusqlite::types::Type::Text))?
.with_timezone(&Utc),
updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?)
.map_err(|_| rusqlite::Error::InvalidColumnType(4, "TEXT".to_string(), rusqlite::types::Type::Text))?
.with_timezone(&Utc),
})
})?;
let mut sessions = Vec::new();
for row in rows {
sessions.push(row?);
// Etapa 1: Buscar sessões por título (FTS5)
let mut title_sessions: Vec<SearchSessionResult> = Vec::new();
{
let mut stmt = self.conn.prepare(
"SELECT s.id, s.title, s.emoji, s.created_at, s.updated_at
FROM sessions s
JOIN sessions_fts ON s.rowid = sessions_fts.rowid
WHERE sessions_fts MATCH ?1
ORDER BY s.updated_at DESC
LIMIT ?2"
)?;
let rows = stmt.query_map(params![&fts_query, limit], |row| {
Ok(SearchSessionResult {
session: ChatSession {
id: row.get(0)?,
title: row.get(1)?,
emoji: row.get(2)?,
created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(3)?)
.map_err(|_| rusqlite::Error::InvalidColumnType(3, "TEXT".to_string(), rusqlite::types::Type::Text))?
.with_timezone(&Utc),
updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?)
.map_err(|_| rusqlite::Error::InvalidColumnType(4, "TEXT".to_string(), rusqlite::types::Type::Text))?
.with_timezone(&Utc),
},
match_count: 1, // Match no título conta como 1
})
})?;
for row in rows {
title_sessions.push(row?);
}
}
// Etapa 2: Buscar sessões por conteúdo de mensagens (FTS5)
let mut content_sessions: Vec<(String, i64)> = Vec::new(); // (session_id, match_count)
{
let mut stmt = self.conn.prepare(
"SELECT m.session_id, COUNT(*) as match_count
FROM messages m
JOIN messages_fts ON m.rowid = messages_fts.rowid
WHERE messages_fts MATCH ?1
GROUP BY m.session_id
ORDER BY match_count DESC
LIMIT ?2"
)?;
let rows = stmt.query_map(params![&fts_query, limit], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
for row in rows {
content_sessions.push(row?);
}
}
// Combinar resultados: priorizar matches por título, adicionar matches por conteúdo
let mut session_map: std::collections::HashMap<String, SearchSessionResult> = std::collections::HashMap::new();
// Adicionar sessões encontradas por título
for session in title_sessions {
session_map.insert(session.session.id.clone(), session);
}
// Adicionar ou atualizar com sessões encontradas por conteúdo
for (session_id, match_count) in content_sessions {
if let Some(existing) = session_map.get_mut(&session_id) {
// Atualizar match_count se já existe
existing.match_count = match_count;
} else {
// Buscar dados da sessão
if let Ok(Some(session)) = self.get_session(&session_id) {
session_map.insert(session_id, SearchSessionResult {
session,
match_count,
});
}
}
}
// Converter para vetor e ordenar por updated_at
let mut sessions: Vec<SearchSessionResult> = session_map.into_values().collect();
sessions.sort_by(|a, b| b.session.updated_at.cmp(&a.session.updated_at));
// Se não encontrou resultados com FTS, tentar busca simples com LIKE (fallback)
if sessions.is_empty() {
let mut stmt = self.conn.prepare(
"SELECT DISTINCT s.id, s.title, s.emoji, s.created_at, s.updated_at
"SELECT s.id, s.title, s.emoji, s.created_at, s.updated_at,
COUNT(CASE WHEN m.content LIKE ?1 THEN 1 END) as match_count
FROM sessions s
LEFT JOIN messages m ON s.id = m.session_id
WHERE s.title LIKE ?1 OR m.content LIKE ?1
GROUP BY s.id, s.title, s.emoji, s.created_at, s.updated_at
ORDER BY s.updated_at DESC
LIMIT ?2"
)?;
let like_query = format!("%{}%", query);
let rows = stmt.query_map(params![like_query, limit], |row| {
Ok(ChatSession {
id: row.get(0)?,
title: row.get(1)?,
emoji: row.get(2)?,
created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(3)?)
.map_err(|_| rusqlite::Error::InvalidColumnType(3, "TEXT".to_string(), rusqlite::types::Type::Text))?
.with_timezone(&Utc),
updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?)
.map_err(|_| rusqlite::Error::InvalidColumnType(4, "TEXT".to_string(), rusqlite::types::Type::Text))?
.with_timezone(&Utc),
Ok(SearchSessionResult {
session: ChatSession {
id: row.get(0)?,
title: row.get(1)?,
emoji: row.get(2)?,
created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(3)?)
.map_err(|_| rusqlite::Error::InvalidColumnType(3, "TEXT".to_string(), rusqlite::types::Type::Text))?
.with_timezone(&Utc),
updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?)
.map_err(|_| rusqlite::Error::InvalidColumnType(4, "TEXT".to_string(), rusqlite::types::Type::Text))?
.with_timezone(&Utc),
},
match_count: row.get(5)?,
})
})?;
+8 -2
View File
@@ -114,6 +114,8 @@ struct SessionSummary {
updated_at: DateTime<Utc>,
preview: String,
platform: String,
#[serde(skip_serializing_if = "Option::is_none")]
match_count: Option<usize>,
}
#[derive(serde::Serialize)]
@@ -407,7 +409,7 @@ fn search_chat_sessions(app_handle: AppHandle, query: String, limit: Option<usiz
.map_err(|e| format!("Failed to open database: {}", e))?;
let search_limit = limit.unwrap_or(50);
let sessions = db.search_sessions(&query, search_limit)
let search_results = db.search_sessions(&query, search_limit)
.map_err(|e| format!("Search failed: {}", e))?;
// Validar existência de cada sessão antes de retornar
@@ -415,7 +417,9 @@ fn search_chat_sessions(app_handle: AppHandle, query: String, limit: Option<usiz
let mut summaries = Vec::new();
let mut orphan_count = 0;
for session in sessions {
for search_result in search_results {
let session = search_result.session;
let match_count = search_result.match_count;
// Verificar se sessão existe no SQLite (já temos)
let exists_in_sqlite = db.get_session(&session.id)
.ok()
@@ -465,6 +469,7 @@ fn search_chat_sessions(app_handle: AppHandle, query: String, limit: Option<usiz
updated_at: session.updated_at, // Já é DateTime<Utc>
preview,
platform: String::new(), // Platform não está no SQLite ainda
match_count: Some(match_count as usize),
});
}
@@ -514,6 +519,7 @@ fn load_chat_sessions(app_handle: AppHandle) -> Result<Vec<SessionSummary>, Stri
updated_at: session.updated_at,
preview: last_msg,
platform: session.platform,
match_count: None,
});
}
}