forked from RiseUP/riseup-squad18
- Avatar do paciente agora persiste após reload (adiciona timestamp para evitar cache) - Agendamento usa patient_id correto ao invés de user_id - Botão de download de PDF desbloqueado com logs detalhados
371 lines
12 KiB
TypeScript
371 lines
12 KiB
TypeScript
import React, { useEffect, useState, useRef } from "react";
|
|
import conniImage from "./images/CONNI.png";
|
|
|
|
/**
|
|
* Chatbot.tsx
|
|
* React + TypeScript component designed for MediConnect.
|
|
* - Floating action button (bottom-right)
|
|
* - Modal / popup chat window
|
|
* - Sends user messages to a backend endpoint (/api/chat) which proxies to an LLM
|
|
* - DOES NOT send/collect any sensitive data (PHI). The frontend strips/flags sensitive fields.
|
|
* - Configurable persona: "Assistente Virtual do MediConnect"
|
|
*
|
|
* Integration notes (short):
|
|
* - Backend should be a Supabase Edge Function (or Cloudflare Worker) at /api/chat
|
|
* - The Edge Function will contain the OpenAI (or other LLM) key and apply the system prompt.
|
|
* - Frontend only uses a short-term session id; it never stores patient-identifying data.
|
|
*/
|
|
|
|
type Message = {
|
|
id: string;
|
|
role: "user" | "assistant" | "system";
|
|
text: string;
|
|
time?: string;
|
|
};
|
|
|
|
interface ChatbotProps {
|
|
className?: string;
|
|
}
|
|
|
|
const Chatbot: React.FC<ChatbotProps> = ({ className = "" }) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [messages, setMessages] = useState<Message[]>([
|
|
{
|
|
id: "welcome",
|
|
role: "assistant",
|
|
text: "Olá! 👋 Sou a Conni, sua Assistente Virtual do MediConnect. Estou aqui para ajudá-lo com dúvidas sobre agendamento de consultas, navegação no sistema, funcionalidades e suporte. Como posso ajudar você hoje?",
|
|
time: new Date().toLocaleTimeString("pt-BR", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
}),
|
|
},
|
|
]);
|
|
const [inputValue, setInputValue] = useState("");
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
|
|
const scrollToBottom = () => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
};
|
|
|
|
useEffect(() => {
|
|
scrollToBottom();
|
|
}, [messages]);
|
|
|
|
/**
|
|
* Sanitize user input before sending.
|
|
* This is a basic approach. For production, you might do more thorough checks.
|
|
*/
|
|
function sanitizeUserMessage(text: string): string {
|
|
// Remove potential HTML/script tags (very naive approach)
|
|
const cleaned = text.replace(/<[^>]*>/g, "");
|
|
// Truncate if too long
|
|
return cleaned.slice(0, 1000);
|
|
}
|
|
|
|
/**
|
|
* Send message to backend /api/chat.
|
|
* The backend returns { reply: string } in JSON.
|
|
*/
|
|
async function callChatApi(userText: string): Promise<string> {
|
|
try {
|
|
// Get auth token from localStorage
|
|
const authData = localStorage.getItem(
|
|
"sb-yuanqfswhberkoevtmfr-auth-token"
|
|
);
|
|
let token = "";
|
|
|
|
if (authData) {
|
|
try {
|
|
const parsedAuth = JSON.parse(authData);
|
|
token = parsedAuth?.access_token || "";
|
|
} catch (e) {
|
|
console.warn("Failed to parse auth token:", e);
|
|
}
|
|
}
|
|
|
|
console.log("[Chatbot] Sending message to /api/chat");
|
|
const response = await fetch("/api/chat", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(token && { Authorization: `Bearer ${token}` }),
|
|
},
|
|
body: JSON.stringify({
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: userText,
|
|
},
|
|
],
|
|
token: token,
|
|
}),
|
|
});
|
|
|
|
console.log("[Chatbot] Response status:", response.status);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
console.error(
|
|
"Chat API error:",
|
|
response.status,
|
|
response.statusText,
|
|
errorText
|
|
);
|
|
return "Desculpe, ocorreu um erro ao processar sua mensagem. Por favor, tente novamente em alguns instantes.";
|
|
}
|
|
|
|
const data = await response.json();
|
|
console.log("[Chatbot] Response data:", data);
|
|
return data.reply || "Sem resposta do servidor.";
|
|
} catch (error) {
|
|
console.error("Erro ao chamar a API de chat:", error);
|
|
return "Não foi possível conectar ao servidor. Verifique sua conexão e tente novamente.";
|
|
}
|
|
}
|
|
|
|
const handleSend = async () => {
|
|
if (!inputValue.trim()) return;
|
|
|
|
const sanitized = sanitizeUserMessage(inputValue);
|
|
const userMessage: Message = {
|
|
id: Date.now().toString(),
|
|
role: "user",
|
|
text: sanitized,
|
|
time: new Date().toLocaleTimeString("pt-BR", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
}),
|
|
};
|
|
|
|
setMessages((prev) => [...prev, userMessage]);
|
|
setInputValue("");
|
|
setIsLoading(true);
|
|
|
|
// Call AI backend
|
|
const reply = await callChatApi(sanitized);
|
|
|
|
const assistantMessage: Message = {
|
|
id: (Date.now() + 1).toString(),
|
|
role: "assistant",
|
|
text: reply,
|
|
time: new Date().toLocaleTimeString("pt-BR", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
}),
|
|
};
|
|
|
|
setMessages((prev) => [...prev, assistantMessage]);
|
|
setIsLoading(false);
|
|
};
|
|
|
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSend();
|
|
}
|
|
};
|
|
|
|
const quickReplies = [
|
|
"Como agendar uma consulta?",
|
|
"Como cancelar um agendamento?",
|
|
"Esqueci minha senha",
|
|
"Onde vejo minhas consultas?",
|
|
];
|
|
|
|
const handleQuickReply = (text: string) => {
|
|
setInputValue(text);
|
|
};
|
|
|
|
return (
|
|
<div className={`fixed bottom-6 left-6 z-40 ${className}`}>
|
|
{/* Floating Button */}
|
|
{!isOpen && (
|
|
<button
|
|
onClick={() => setIsOpen(true)}
|
|
className="bg-blue-600 hover:bg-blue-700 text-white rounded-full p-3 shadow-lg transition-all hover:scale-110 flex items-center gap-2 group"
|
|
aria-label="Abrir chat de ajuda"
|
|
>
|
|
{/* MessageCircle Icon (inline SVG) */}
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
className="w-6 h-6"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
|
/>
|
|
</svg>
|
|
<span className="font-medium hidden sm:inline">
|
|
Precisa de ajuda?
|
|
</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* Chat Window */}
|
|
{isOpen && (
|
|
<div className="bg-white rounded-lg shadow-2xl w-96 max-w-[calc(100vw-3rem)] max-h-[75vh] flex flex-col">
|
|
{/* Header */}
|
|
<div className="bg-gradient-to-r from-blue-600 to-blue-700 text-white p-4 rounded-t-lg flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-full overflow-hidden bg-white">
|
|
<img
|
|
src={conniImage}
|
|
alt="Conni"
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<h3 className="font-semibold">
|
|
Conni - Assistente MediConnect
|
|
</h3>
|
|
<p className="text-xs text-blue-100">Online • AI-Powered</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => setIsOpen(false)}
|
|
className="hover:bg-white/20 rounded-full p-1 transition"
|
|
aria-label="Fechar chat"
|
|
>
|
|
{/* X Icon */}
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
className="w-5 h-5"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Messages */}
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-gray-50">
|
|
{messages.map((message) => (
|
|
<div
|
|
key={message.id}
|
|
className={`flex ${
|
|
message.role === "user" ? "justify-end" : "justify-start"
|
|
}`}
|
|
>
|
|
<div
|
|
className={`max-w-[80%] rounded-lg p-3 ${
|
|
message.role === "user"
|
|
? "bg-blue-600 text-white"
|
|
: "bg-white text-gray-800 shadow"
|
|
}`}
|
|
>
|
|
<p className="text-sm whitespace-pre-line">{message.text}</p>
|
|
{message.time && (
|
|
<p
|
|
className={`text-xs mt-1 ${
|
|
message.role === "user"
|
|
? "text-blue-100"
|
|
: "text-gray-400"
|
|
}`}
|
|
>
|
|
{message.time}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{isLoading && (
|
|
<div className="flex justify-start">
|
|
<div className="bg-white text-gray-800 shadow rounded-lg p-3">
|
|
<div className="flex gap-1">
|
|
<div
|
|
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
|
|
style={{ animationDelay: "0ms" }}
|
|
></div>
|
|
<div
|
|
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
|
|
style={{ animationDelay: "150ms" }}
|
|
></div>
|
|
<div
|
|
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
|
|
style={{ animationDelay: "300ms" }}
|
|
></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{/* Quick Replies */}
|
|
{messages.length <= 1 && (
|
|
<div className="px-4 py-2 border-t bg-white">
|
|
<p className="text-xs text-gray-500 mb-2">
|
|
Perguntas frequentes:
|
|
</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{quickReplies.map((reply, index) => (
|
|
<button
|
|
key={index}
|
|
onClick={() => handleQuickReply(reply)}
|
|
className="text-xs bg-blue-50 hover:bg-blue-100 text-blue-700 px-3 py-1 rounded-full transition"
|
|
>
|
|
{reply}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Input */}
|
|
<div className="p-4 border-t bg-white rounded-b-lg">
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={inputValue}
|
|
onChange={(e) => setInputValue(e.target.value)}
|
|
onKeyPress={handleKeyPress}
|
|
placeholder="Digite sua mensagem..."
|
|
className="flex-1 border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
/>
|
|
<button
|
|
onClick={handleSend}
|
|
disabled={!inputValue.trim() || isLoading}
|
|
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-white rounded-lg p-2 transition"
|
|
aria-label="Enviar mensagem"
|
|
>
|
|
{/* Send Icon */}
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
className="w-5 h-5"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Chatbot;
|