Componentes de Chat
Esta página documenta os componentes específicos da interface de chat do APAH Assistant.
Visão Geral
A interface de chat é composta por vários componentes que trabalham juntos:
ChatInterface
├── ChatHistory (sidebar)
│ └── ChatHistoryItem
├── ChatMessages (área principal)
│ └── ChatMessage
│ ├── MessageContent
│ └── MessageActions
└── ChatInput
└── ChatInputActions
ChatInterface
Componente principal que orquestra toda a interface de chat.
Localização
src/app/_components/chat/chat-interface.tsx
Props
interface ChatInterfaceProps {
initialHistory?: Chat[];
currentChatId?: string;
}
Uso
import { ChatInterface } from "@/app/_components/chat/chat-interface";
export default function ChatPage() {
return <ChatInterface initialHistory={history} />;
}
Estrutura
export function ChatInterface({ initialHistory, currentChatId }: ChatInterfaceProps) {
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false);
return (
<div className="flex h-full">
{/* Sidebar com histórico */}
<ChatHistory
history={initialHistory}
currentId={currentChatId}
/>
{/* Área principal */}
<div className="flex flex-1 flex-col">
<ChatMessages messages={messages} isLoading={isLoading} />
<ChatInput onSend={handleSend} disabled={isLoading} />
</div>
</div>
);
}
ChatHistory
Sidebar com lista de conversas anteriores.
Localização
src/app/_components/chat/chat-history.tsx
Props
interface ChatHistoryProps {
history: Chat[];
currentId?: string;
onSelect: (chatId: string) => void;
onDelete: (chatId: string) => void;
onNew: () => void;
}
Estrutura Visual
┌─────────────────────┐
│ [+ Nova Conversa] │
├─────────────────────┤
│ 🕐 Hoje │
│ ├─ Conversa 1 ⋮ │
│ └─ Conversa 2 ⋮ │
│ │
│ 🕐 Ontem │
│ └─ Conversa 3 ⋮ │
│ │
│ 🕐 Esta semana │
│ └─ Conversa 4 ⋮ │
└─────────────────────┘
Implementação
export function ChatHistory({ history, currentId, onSelect, onDelete, onNew }: ChatHistoryProps) {
const groupedHistory = useMemo(() => groupByDate(history), [history]);
return (
<aside className="w-64 border-r bg-muted/30">
<div className="p-4">
<Button onClick={onNew} className="w-full">
<Plus className="mr-2 h-4 w-4" />
Nova Conversa
</Button>
</div>
<ScrollArea className="h-[calc(100vh-120px)]">
{Object.entries(groupedHistory).map(([date, chats]) => (
<div key={date} className="px-2 py-2">
<p className="px-2 text-xs text-muted-foreground">{date}</p>
{chats.map((chat) => (
<ChatHistoryItem
key={chat.id}
chat={chat}
isActive={chat.id === currentId}
onSelect={() => onSelect(chat.id)}
onDelete={() => onDelete(chat.id)}
/>
))}
</div>
))}
</ScrollArea>
</aside>
);
}
ChatMessages
Área de exibição das mensagens da conversa.
Localização
src/app/_components/chat/chat-messages.tsx
Props
interface ChatMessagesProps {
messages: Message[];
isLoading: boolean;
}
interface Message {
id: string;
role: "user" | "assistant" | "system";
content: string;
createdAt: Date;
}
Estrutura
export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
const scrollRef = useRef<HTMLDivElement>(null);
// Auto-scroll para novas mensagens
useEffect(() => {
scrollRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
return (
<ScrollArea className="flex-1 p-4">
<div className="mx-auto max-w-3xl space-y-4">
{messages.length === 0 ? (
<EmptyState />
) : (
messages.map((message) => (
<ChatMessage key={message.id} message={message} />
))
)}
{isLoading && <LoadingIndicator />}
<div ref={scrollRef} />
</div>
</ScrollArea>
);
}
ChatMessage
Componente individual de mensagem.
Localização
src/app/_components/chat/chat-message.tsx
Props
interface ChatMessageProps {
message: Message;
onFeedback?: (type: "positive" | "negative") => void;
onCopy?: () => void;
}
Estrutura Visual
Mensagem do Utilizador:
┌─────────────────────────────────────────────────────────┐
│ ┌──────────┐ │
│ Qual é o tratamento para HAP? │ 👤 │ │
│ └──────────┘ │
└─────────────────────────────────────────────────────────┘
Mensagem do Assistente:
┌─────────────────────────────────────────────────────────┐
│ ┌──────────┐ │
│ │ 🤖 │ O tratamento da Hipertensão Arterial │
│ └──────────┘ Pulmonar (HAP) envolve várias abordagens: │
│ │
│ 1. **Terapia específica**: │
│ - Antagonistas dos receptores... │
│ - Inibidores da PDE-5... │
│ │
│ [📋 Copiar] [👍] [👎] │
└─────────────────────────────────────────────────────────┘
Implementação
export function ChatMessage({ message, onFeedback, onCopy }: ChatMessageProps) {
const isUser = message.role === "user";
return (
<div className={cn(
"flex gap-3",
isUser ? "flex-row-reverse" : "flex-row"
)}>
{/* Avatar */}
<Avatar className="h-8 w-8">
{isUser ? (
<AvatarFallback>U</AvatarFallback>
) : (
<AvatarImage src="/bot-avatar.png" />
)}
</Avatar>
{/* Conteúdo */}
<div className={cn(
"max-w-[80%] rounded-lg px-4 py-2",
isUser
? "bg-primary text-primary-foreground"
: "bg-muted"
)}>
<MessageContent content={message.content} />
{/* Ações (apenas para mensagens do assistente) */}
{!isUser && (
<MessageActions
onCopy={onCopy}
onFeedback={onFeedback}
/>
)}
</div>
</div>
);
}
MessageContent
Renderização do conteúdo da mensagem com suporte a Markdown.
Props
interface MessageContentProps {
content: string;
}
Implementação
import ReactMarkdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
export function MessageContent({ content }: MessageContentProps) {
return (
<ReactMarkdown
className="prose prose-sm dark:prose-invert"
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || "");
return !inline && match ? (
<SyntaxHighlighter
language={match[1]}
PreTag="div"
{...props}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
) : (
<code className={className} {...props}>
{children}
</code>
);
},
}}
>
{content}
</ReactMarkdown>
);
}
ChatInput
Campo de entrada de mensagens.
Localização
src/app/_components/chat/chat-input.tsx
Props
interface ChatInputProps {
onSend: (message: string) => void;
disabled?: boolean;
placeholder?: string;
}
Estrutura Visual
┌─────────────────────────────────────────────────────────┐
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Escreva uma mensagem... ➤ │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Implementação
export function ChatInput({ onSend, disabled, placeholder }: ChatInputProps) {
const [value, setValue] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleSubmit = () => {
if (value.trim() && !disabled) {
onSend(value.trim());
setValue("");
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
// Auto-resize textarea
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
}
}, [value]);
return (
<div className="border-t p-4">
<div className="mx-auto flex max-w-3xl gap-2">
<Textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder ?? "Escreva uma mensagem..."}
disabled={disabled}
className="min-h-11 max-h-[200px] resize-none"
rows={1}
/>
<Button
onClick={handleSubmit}
disabled={disabled || !value.trim()}
size="icon"
>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
);
}
LoadingIndicator
Indicador de carregamento durante streaming.
Implementação
export function LoadingIndicator() {
return (
<div className="flex gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src="/bot-avatar.png" />
</Avatar>
<div className="flex items-center gap-1 rounded-lg bg-muted px-4 py-2">
<span className="h-2 w-2 animate-bounce rounded-full bg-foreground/50" />
<span className="h-2 w-2 animate-bounce rounded-full bg-foreground/50 [animation-delay:0.2s]" />
<span className="h-2 w-2 animate-bounce rounded-full bg-foreground/50 [animation-delay:0.4s]" />
</div>
</div>
);
}
EmptyState
Estado vazio quando não há mensagens.
Implementação
export function EmptyState() {
const suggestions = [
"Quais são os sintomas da HAP?",
"Como é feito o diagnóstico?",
"Quais são as opções de tratamento?",
];
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Bot className="h-12 w-12 text-muted-foreground" />
<h2 className="mt-4 text-xl font-semibold">
Olá! Sou o APAH Assistant
</h2>
<p className="mt-2 text-muted-foreground">
Como posso ajudá-lo hoje?
</p>
<div className="mt-6 flex flex-wrap justify-center gap-2">
{suggestions.map((suggestion) => (
<Button
key={suggestion}
variant="outline"
size="sm"
onClick={() => handleSuggestion(suggestion)}
>
{suggestion}
</Button>
))}
</div>
</div>
);
}
Hooks Relacionados
useChatHistory
export function useChatHistory() {
const { data, isLoading, refetch } = api.chat.getHistory.useQuery();
const createChat = api.chat.create.useMutation({
onSuccess: () => refetch(),
});
const deleteChat = api.chat.delete.useMutation({
onSuccess: () => refetch(),
});
return {
history: data ?? [],
isLoading,
createChat: createChat.mutate,
deleteChat: deleteChat.mutate,
};
}
Streaming de Respostas
O chat utiliza streaming para mostrar respostas em tempo real:
const handleSend = async (message: string) => {
// Adicionar mensagem do utilizador
setMessages((prev) => [...prev, { role: "user", content: message }]);
setIsLoading(true);
// Iniciar stream
const response = await fetch("/api/chat", {
method: "POST",
body: JSON.stringify({ messages, chatId }),
});
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let assistantMessage = "";
// Adicionar mensagem vazia do assistente
setMessages((prev) => [...prev, { role: "assistant", content: "" }]);
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
assistantMessage += chunk;
// Atualizar última mensagem
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1].content = assistantMessage;
return updated;
});
}
setIsLoading(false);
};