- 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.
186 lines
7.0 KiB
TypeScript
186 lines
7.0 KiB
TypeScript
import ReactMarkdown from 'react-markdown';
|
|
import remarkGfm from 'remark-gfm';
|
|
import { useState, useMemo } from 'react';
|
|
import { cn } from "@/lib/utils";
|
|
import { Message } from "@/hooks/use-chat";
|
|
import { Bot, User } from "lucide-react";
|
|
import { markdownRenderers } from "./markdown-renderers";
|
|
import { MessageActions } from "./message-actions";
|
|
import { MessageBackground } from "./message-background";
|
|
import { MessageStepThinking } from "./message-step-thinking";
|
|
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, highlightTerm, highlightIndex = 0, messageIndex }: ChatMessageProps) {
|
|
// Verificar se é mensagem de processo de pensamento
|
|
const isThinkingMessage = message.metadata &&
|
|
typeof message.metadata === 'object' &&
|
|
'type' in message.metadata &&
|
|
message.metadata.type === 'thinking';
|
|
|
|
if (isThinkingMessage) {
|
|
return <MessageStepThinking metadata={message.metadata as ThinkingMessageMetadata} />;
|
|
}
|
|
|
|
const isUser = message.role === 'user';
|
|
const [selected, setSelected] = useState(false);
|
|
const isEmpty = !message.content || message.content.trim() === '';
|
|
|
|
// Memoizar processamento de conteúdo
|
|
const processedContent = useMemo(() => {
|
|
let content = message.content || '';
|
|
// Remove metadata tags
|
|
content = content.replace(/<metadata>[\s\S]*?<\/metadata>/gi, '').trim();
|
|
|
|
// Also remove common prefixes
|
|
const prefixes = [
|
|
'Bloco oculto de metadados:', 'Metadata:', 'Metadados:',
|
|
'Hidden metadata block:', 'JSON metadata:', '---'
|
|
];
|
|
|
|
for (const prefix of prefixes) {
|
|
const regex = new RegExp(`${prefix}\\s*$`, 'i');
|
|
if (regex.test(content)) {
|
|
content = content.replace(regex, '').trim();
|
|
}
|
|
}
|
|
|
|
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
|
|
role={isUser ? 'user' : 'assistant'}
|
|
state={selected ? 'selected' : 'normal'}
|
|
className={cn(
|
|
"cursor-default transition-colors rounded-xl",
|
|
selected && "bg-muted/40",
|
|
isUser ? "mb-2" : "mb-8"
|
|
)}
|
|
onClick={() => setSelected((v) => !v)}
|
|
tabIndex={0}
|
|
>
|
|
<div className="flex-shrink-0 mt-0.5">
|
|
<div className={cn(
|
|
"w-8 h-8 rounded-full flex items-center justify-center shadow-sm ring-1 ring-border/50 backdrop-blur-sm",
|
|
isUser
|
|
? "bg-background text-muted-foreground"
|
|
: "bg-primary/10 text-primary ring-primary/20"
|
|
)}>
|
|
{isUser ? <User className="w-4 h-4" /> : <Bot className="w-5 h-5" />}
|
|
</div>
|
|
</div>
|
|
|
|
<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"
|
|
dangerouslySetInnerHTML={highlightedContent ? { __html: highlightedContent } : undefined}
|
|
>
|
|
{!highlightedContent && processedContent}
|
|
</div>
|
|
) : (
|
|
// Estilo Assistant
|
|
isEmpty && isStreaming ? (
|
|
<div className="flex items-center gap-2 text-muted-foreground py-1">
|
|
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
|
|
<span className="text-sm font-medium">Gerando resposta...</span>
|
|
</div>
|
|
) : (
|
|
<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"
|
|
)}>
|
|
{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" />
|
|
)}
|
|
</div>
|
|
)
|
|
)}
|
|
</div>
|
|
|
|
{!isUser && !isStreaming && (
|
|
<div className={cn(
|
|
"absolute -bottom-8 left-12 flex items-center gap-2 opacity-0 transition-opacity duration-200",
|
|
"group-hover:opacity-100",
|
|
selected && "opacity-100"
|
|
)}>
|
|
<MessageActions content={message.content} role={message.role} />
|
|
</div>
|
|
)}
|
|
</MessageBackground>
|
|
);
|
|
}
|
|
|
|
|
|
|