useChat.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572
  1. import { useState, useEffect, useCallback } from "react";
  2. import { notifications } from "@/lib/notifications";
  3. import { generateReportFromMessages } from "@/utils/reports";
  4. import { Message, ChatState, ChatResponse, SuggestedPrompt, MedicalAlert } from "@/components/chatbot/types";
  5. export const MAX_MESSAGES = 5;
  6. interface UseChatProps {
  7. chatType: "medical" | "psychological";
  8. }
  9. export const useChat = ({ chatType }: UseChatProps) => {
  10. const [messages, setMessages] = useState<Message[]>([]);
  11. const [messageCount, setMessageCount] = useState(0);
  12. const [isLimitReached, setIsLimitReached] = useState(false);
  13. const [isGeneratingReport, setIsGeneratingReport] = useState(false);
  14. const [generatedReport, setGeneratedReport] = useState("");
  15. const [inputMessage, setInputMessage] = useState("");
  16. const [isLoading, setIsLoading] = useState(false);
  17. const [reportGenerated, setReportGenerated] = useState(false);
  18. const [showSuggestions, setShowSuggestions] = useState(true);
  19. const [showCompletedBanner, setShowCompletedBanner] = useState(false);
  20. const [currentSuggestions, setCurrentSuggestions] = useState<SuggestedPrompt[]>([]);
  21. const [showDynamicSuggestions, setShowDynamicSuggestions] = useState(false);
  22. const [inputDisabledForSuggestions, setInputDisabledForSuggestions] = useState(false);
  23. const [medicalAlertDetected, setMedicalAlertDetected] = useState<MedicalAlert | null>(null);
  24. const [isSchedulingFromAlert, setIsSchedulingFromAlert] = useState(false);
  25. const [showMedicalAlertBanner, setShowMedicalAlertBanner] = useState(false);
  26. const [crisisDetected, setCrisisDetected] = useState(false);
  27. const [showCrisisBanner, setShowCrisisBanner] = useState(false);
  28. const isPsychological = chatType === "psychological";
  29. const remainingMessages = isPsychological ? Infinity : Math.max(0, MAX_MESSAGES - messageCount);
  30. const isLastMessage = !isPsychological && remainingMessages === 1;
  31. // Cargar estado desde localStorage al iniciar
  32. useEffect(() => {
  33. const storageKey = `chatState_${chatType}`;
  34. const savedState = localStorage.getItem(storageKey);
  35. if (savedState) {
  36. try {
  37. const parsed: ChatState = JSON.parse(savedState);
  38. // Convertir timestamps de string a Date
  39. const messagesWithDates = (parsed.messages || []).map(msg => ({
  40. ...msg,
  41. timestamp: msg.timestamp ? new Date(msg.timestamp) : undefined
  42. }));
  43. setMessages(messagesWithDates);
  44. setMessageCount(parsed.messageCount || 0);
  45. setIsLimitReached(parsed.isLimitReached || false);
  46. setReportGenerated(parsed.reportGenerated || false);
  47. setCurrentSuggestions(parsed.currentSuggestions || []);
  48. // Si hay mensajes guardados, ocultar sugerencias iniciales
  49. if (messagesWithDates && messagesWithDates.length > 0) {
  50. setShowSuggestions(false);
  51. // Mostrar sugerencias dinámicas si el último mensaje es del asistente y no está en streaming
  52. const lastMessage = messagesWithDates[messagesWithDates.length - 1];
  53. if (lastMessage?.role === "assistant" && !lastMessage.isStreaming && lastMessage.suggestions) {
  54. setCurrentSuggestions(lastMessage.suggestions);
  55. setShowDynamicSuggestions(true);
  56. }
  57. } else {
  58. // Si no hay mensajes, asegurarse de que las sugerencias estén visibles
  59. setShowSuggestions(true);
  60. }
  61. } catch (error) {
  62. console.error("Error loading chat state:", error);
  63. // En caso de error, asegurar que las sugerencias estén visibles
  64. setShowSuggestions(true);
  65. }
  66. }
  67. }, [chatType]);
  68. // Guardar estado en localStorage cuando cambie
  69. useEffect(() => {
  70. const storageKey = `chatState_${chatType}`;
  71. const stateToSave: ChatState = {
  72. messages,
  73. messageCount,
  74. isLimitReached,
  75. reportGenerated,
  76. currentSuggestions,
  77. };
  78. localStorage.setItem(storageKey, JSON.stringify(stateToSave));
  79. }, [messages, messageCount, isLimitReached, reportGenerated, currentSuggestions, chatType]);
  80. // Función para generar reporte usando useCallback para evitar re-renderizados
  81. const generateMedicalReport = useCallback(async () => {
  82. if (isGeneratingReport || reportGenerated) return;
  83. setIsGeneratingReport(true);
  84. try {
  85. // Mostrar toast de generación
  86. notifications.records.generating();
  87. // Generar reporte
  88. const report = generateReportFromMessages(messages);
  89. setGeneratedReport(report);
  90. setReportGenerated(true);
  91. // Guardar en la base de datos
  92. const response = await fetch("/api/chat/report", {
  93. method: "POST",
  94. headers: {
  95. "Content-Type": "application/json",
  96. },
  97. body: JSON.stringify({
  98. content: report,
  99. messages: messages,
  100. chatType,
  101. }),
  102. });
  103. if (response.ok) {
  104. notifications.records.saved();
  105. } else {
  106. notifications.records.saveError();
  107. }
  108. } catch (error) {
  109. console.error("Error generando reporte:", error);
  110. notifications.records.generateError();
  111. } finally {
  112. setIsGeneratingReport(false);
  113. }
  114. }, [messages, isGeneratingReport, reportGenerated]);
  115. // Efecto para generar reporte cuando se alcanza el límite (solo para chat médico)
  116. useEffect(() => {
  117. if (
  118. !isPsychological &&
  119. messageCount >= MAX_MESSAGES &&
  120. !isLimitReached &&
  121. !reportGenerated &&
  122. !isLoading
  123. ) {
  124. // Solo generar reporte si el último mensaje es del asistente y no está en streaming
  125. if (
  126. messages.length > 0 &&
  127. messages[messages.length - 1].role === "assistant" &&
  128. !messages[messages.length - 1].isStreaming
  129. ) {
  130. // Esperar un poco antes de mostrar el banner para que el usuario vea la respuesta
  131. setTimeout(() => {
  132. setShowCompletedBanner(true);
  133. generateMedicalReport();
  134. }, 2000); // 2 segundos de delay
  135. }
  136. }
  137. }, [
  138. isPsychological,
  139. messageCount,
  140. isLimitReached,
  141. reportGenerated,
  142. generateMedicalReport,
  143. messages,
  144. isLoading,
  145. ]);
  146. const sendMessage = async (customMessage?: string) => {
  147. const messageToSend = customMessage || inputMessage.trim();
  148. if (!messageToSend || isLimitReached || isLoading) return;
  149. console.log("🚀 [CHAT] Iniciando envío de mensaje:", messageToSend);
  150. console.log("📊 [CHAT] Estado actual - Mensajes:", messageCount, "Límite alcanzado:", isLimitReached);
  151. const userMessage: Message = {
  152. role: "user",
  153. content: messageToSend,
  154. timestamp: new Date(),
  155. };
  156. setMessages((prev) => [...prev, userMessage]);
  157. setMessageCount((prev) => prev + 1);
  158. setInputMessage("");
  159. setIsLoading(true);
  160. setShowSuggestions(false); // Ocultar sugerencias iniciales después del primer mensaje
  161. setShowDynamicSuggestions(false); // Ocultar sugerencias dinámicas cuando el usuario envía un mensaje
  162. setInputDisabledForSuggestions(false); // Habilitar input inmediatamente cuando el usuario envía un mensaje
  163. const startTime = Date.now();
  164. console.log("⏱️ [CHAT] Iniciando petición a API en:", new Date().toISOString());
  165. try {
  166. // Llamada a OpenRouter API con STREAMING
  167. console.log("📡 [CHAT] Enviando petición a /api/chat...");
  168. const response = await fetch("/api/chat", {
  169. method: "POST",
  170. headers: {
  171. "Content-Type": "application/json",
  172. },
  173. body: JSON.stringify({
  174. message: messageToSend,
  175. messages: [...messages, userMessage],
  176. chatType,
  177. }),
  178. });
  179. const responseTime = Date.now() - startTime;
  180. console.log("✅ [CHAT] Conexión establecida en:", responseTime, "ms");
  181. console.log("📋 [CHAT] Status de respuesta:", response.status, response.statusText);
  182. if (response.ok) {
  183. console.log("🔄 [CHAT] Procesando stream en tiempo real...");
  184. // Crear mensaje del asistente vacío para ir llenándolo
  185. const assistantMessage: Message = {
  186. role: "assistant",
  187. content: "",
  188. timestamp: new Date(),
  189. isStreaming: true,
  190. };
  191. setMessages((prev) => [...prev, assistantMessage]);
  192. console.log("🎬 [CHAT] Iniciando recepción de stream...");
  193. // Procesar el stream
  194. const reader = response.body?.getReader();
  195. const decoder = new TextDecoder();
  196. let buffer = "";
  197. let fullContent = "";
  198. let metadata: { medicalAlert?: string; suggestions?: SuggestedPrompt[]; crisisDetected?: boolean } = {};
  199. if (!reader) {
  200. throw new Error("No se pudo obtener el reader del stream");
  201. }
  202. try {
  203. while (true) {
  204. const { done, value } = await reader.read();
  205. if (done) {
  206. console.log("✅ [CHAT] Stream completado");
  207. break;
  208. }
  209. // Decodificar el chunk
  210. buffer += decoder.decode(value, { stream: true });
  211. // Procesar eventos SSE (formato: data: {json}\n\n)
  212. const lines = buffer.split('\n\n');
  213. buffer = lines.pop() || ""; // Guardar la última línea incompleta
  214. for (const line of lines) {
  215. if (line.startsWith('data: ')) {
  216. const data = line.slice(6); // Remover "data: "
  217. if (data === '[DONE]') {
  218. console.log("� [CHAT] Recibida señal de finalización");
  219. break;
  220. }
  221. try {
  222. const parsed = JSON.parse(data);
  223. if (parsed.type === 'content') {
  224. // Acumular contenido y actualizar en tiempo real
  225. fullContent += parsed.content;
  226. setMessages((prev) =>
  227. prev.map((msg, index) =>
  228. index === prev.length - 1 && msg.role === "assistant"
  229. ? { ...msg, content: fullContent }
  230. : msg
  231. )
  232. );
  233. } else if (parsed.type === 'metadata') {
  234. // Guardar metadatos
  235. metadata = {
  236. medicalAlert: parsed.medicalAlert,
  237. suggestions: parsed.suggestions,
  238. crisisDetected: parsed.crisisDetected
  239. };
  240. console.log("📦 [CHAT] Metadatos recibidos:", metadata);
  241. }
  242. } catch (parseError) {
  243. console.warn("⚠️ [CHAT] Error parseando evento SSE:", parseError);
  244. }
  245. }
  246. }
  247. }
  248. } finally {
  249. reader.releaseLock();
  250. }
  251. const totalTime = Date.now() - startTime;
  252. console.log("✅ [CHAT] Respuesta completa procesada en:", totalTime, "ms");
  253. // Detectar crisis en chat psicológico
  254. if (isPsychological && metadata.crisisDetected) {
  255. console.log("🚨 [CHAT] Crisis detectada en chat psicológico");
  256. setCrisisDetected(true);
  257. setShowCrisisBanner(true);
  258. }
  259. // Detectar alerta médica
  260. if (metadata.medicalAlert && metadata.medicalAlert !== "NO_AGENDAR" && !medicalAlertDetected) {
  261. console.log("🚨 [CHAT] Alerta médica detectada:", metadata.medicalAlert);
  262. setMedicalAlertDetected(metadata.medicalAlert as MedicalAlert);
  263. setShowMedicalAlertBanner(true);
  264. }
  265. // Marcar mensaje como completado y agregar metadatos
  266. setMessages((prev) =>
  267. prev.map((msg, index) =>
  268. index === prev.length - 1 && msg.role === "assistant"
  269. ? {
  270. ...msg,
  271. isStreaming: false,
  272. medicalAlert: metadata.medicalAlert as MedicalAlert | undefined,
  273. suggestions: metadata.suggestions
  274. }
  275. : msg
  276. )
  277. );
  278. // Mostrar sugerencias dinámicas después del streaming si no es el último mensaje
  279. // En chat psicológico no mostrar sugerencias dinámicas
  280. if (!isPsychological && messageCount + 1 < MAX_MESSAGES && metadata.suggestions) {
  281. setCurrentSuggestions(metadata.suggestions);
  282. setShowDynamicSuggestions(true);
  283. setInputDisabledForSuggestions(true);
  284. // Habilitar el input después de 3 segundos para dar tiempo a leer las sugerencias
  285. setTimeout(() => {
  286. setInputDisabledForSuggestions(false);
  287. }, 3000);
  288. }
  289. } else {
  290. console.log("❌ [CHAT] Error en respuesta de API - Status:", response.status);
  291. const errorText = await response.text();
  292. console.log("📄 [CHAT] Contenido del error:", errorText);
  293. // Respuesta de fallback si la API falla
  294. const fallbackMessage: Message = {
  295. role: "assistant",
  296. content: `Gracias por tu consulta sobre "${messageToSend}". Como asistente médico virtual, puedo ayudarte con información general sobre salud, pero recuerda que no puedo reemplazar la consulta con un profesional médico. ¿Hay algo específico que te gustaría saber?`,
  297. timestamp: new Date(),
  298. medicalAlert: "NO_AGENDAR",
  299. suggestions: [
  300. {
  301. title: "Síntomas de Gripe",
  302. emoji: "🤒",
  303. prompt: "¿Cuáles son los síntomas más comunes de la gripe y cómo diferenciarla de un resfriado?"
  304. },
  305. {
  306. title: "Fortalecer Inmunidad",
  307. emoji: "🛡️",
  308. prompt: "¿Qué alimentos y hábitos me ayudan a fortalecer mi sistema inmunológico?"
  309. },
  310. {
  311. title: "Cuándo Consultar",
  312. emoji: "🩺",
  313. prompt: "¿Cuándo debo consultar a un médico por síntomas de gripe o resfriado?"
  314. }
  315. ],
  316. };
  317. setMessages((prev) => [...prev, fallbackMessage]);
  318. // Mostrar sugerencias dinámicas si no es el último mensaje
  319. // En chat psicológico no mostrar sugerencias dinámicas
  320. if (!isPsychological && messageCount + 1 < MAX_MESSAGES) {
  321. setCurrentSuggestions(fallbackMessage.suggestions || []);
  322. setShowDynamicSuggestions(true);
  323. setInputDisabledForSuggestions(true);
  324. // Habilitar el input después de 3 segundos
  325. setTimeout(() => {
  326. setInputDisabledForSuggestions(false);
  327. }, 3000);
  328. }
  329. }
  330. } catch (error) {
  331. const totalTime = Date.now() - startTime;
  332. console.error("💥 [CHAT] Error enviando mensaje después de", totalTime, "ms:", error);
  333. console.error("🔍 [CHAT] Tipo de error:", error instanceof TypeError ? 'Network/Fetch Error' : 'Other Error');
  334. console.error("📋 [CHAT] Detalles del error:", {
  335. name: error instanceof Error ? error.name : 'Unknown',
  336. message: error instanceof Error ? error.message : String(error),
  337. stack: error instanceof Error ? error.stack : 'No stack trace'
  338. });
  339. // Respuesta de fallback en caso de error
  340. const fallbackMessage: Message = {
  341. role: "assistant",
  342. content:
  343. "Lo siento, estoy teniendo problemas técnicos. Por favor, intenta de nuevo en unos momentos.",
  344. timestamp: new Date(),
  345. medicalAlert: "NO_AGENDAR",
  346. suggestions: [
  347. {
  348. title: "Reintentar Consulta",
  349. emoji: "🔄",
  350. prompt: "¿Puedes intentar responder mi pregunta anterior sobre salud?"
  351. },
  352. {
  353. title: "Síntomas Comunes",
  354. emoji: "🩺",
  355. prompt: "¿Cuáles son los síntomas más comunes que debo vigilar en mi salud?"
  356. },
  357. {
  358. title: "Emergencias Médicas",
  359. emoji: "🚨",
  360. prompt: "¿Cuándo debo considerar que un síntoma es una emergencia médica?"
  361. }
  362. ],
  363. };
  364. setMessages((prev) => [...prev, fallbackMessage]);
  365. // Mostrar sugerencias dinámicas si no es el último mensaje
  366. // En chat psicológico no mostrar sugerencias dinámicas
  367. if (!isPsychological && messageCount + 1 < MAX_MESSAGES) {
  368. setCurrentSuggestions(fallbackMessage.suggestions || []);
  369. setShowDynamicSuggestions(true);
  370. setInputDisabledForSuggestions(true);
  371. // Habilitar el input después de 3 segundos
  372. setTimeout(() => {
  373. setInputDisabledForSuggestions(false);
  374. }, 3000);
  375. }
  376. } finally {
  377. const totalTime = Date.now() - startTime;
  378. console.log("🏁 [CHAT] Procesamiento completado en:", totalTime, "ms");
  379. setIsLoading(false);
  380. }
  381. };
  382. const resetChat = () => {
  383. setMessages([]);
  384. setMessageCount(0);
  385. setIsLimitReached(false);
  386. setIsGeneratingReport(false);
  387. setGeneratedReport("");
  388. setInputMessage("");
  389. setReportGenerated(false);
  390. setShowSuggestions(true); // Mostrar sugerencias iniciales al resetear
  391. setShowCompletedBanner(false);
  392. setCurrentSuggestions([]);
  393. setShowDynamicSuggestions(false);
  394. setInputDisabledForSuggestions(false);
  395. setMedicalAlertDetected(null);
  396. setShowMedicalAlertBanner(false);
  397. setIsSchedulingFromAlert(false);
  398. setCrisisDetected(false);
  399. setShowCrisisBanner(false);
  400. // Limpiar localStorage
  401. const storageKey = `chatState_${chatType}`;
  402. localStorage.removeItem(storageKey);
  403. notifications.chat.newConsultation();
  404. };
  405. const dismissCompletedBanner = () => {
  406. setShowCompletedBanner(false);
  407. };
  408. const handleResetWithReport = async () => {
  409. if (messages.length === 0) {
  410. resetChat();
  411. return;
  412. }
  413. try {
  414. // Generar reporte con la conversación actual
  415. const currentReport = generateReportFromMessages(messages);
  416. // Guardar el reporte en la base de datos
  417. const response = await fetch("/api/chat/report", {
  418. method: "POST",
  419. headers: {
  420. "Content-Type": "application/json",
  421. },
  422. body: JSON.stringify({
  423. content: currentReport,
  424. messages: messages,
  425. }),
  426. });
  427. if (response.ok) {
  428. notifications.records.savedNewConsultation();
  429. resetChat();
  430. } else {
  431. notifications.records.saveError();
  432. }
  433. } catch (error) {
  434. console.error("Error al generar reporte:", error);
  435. notifications.records.generateError();
  436. }
  437. };
  438. const handleScheduleFromAlert = async (onSuccess: (recordId: string) => void) => {
  439. console.log("🚀 [useChat] handleScheduleFromAlert iniciado");
  440. setIsSchedulingFromAlert(true);
  441. try {
  442. // Generar reporte con la conversación actual
  443. const currentReport = generateReportFromMessages(messages);
  444. console.log("📝 [useChat] Reporte generado, longitud:", currentReport.length);
  445. // Guardar el reporte en la base de datos
  446. const response = await fetch("/api/chat/report", {
  447. method: "POST",
  448. headers: {
  449. "Content-Type": "application/json",
  450. },
  451. body: JSON.stringify({
  452. content: currentReport,
  453. messages: messages,
  454. }),
  455. });
  456. if (response.ok) {
  457. const data = await response.json();
  458. const recordId = data.id; // ID del Record creado
  459. console.log("✅ [useChat] Record creado exitosamente, ID:", recordId);
  460. // Callback con el recordId para abrir el modal de cita
  461. onSuccess(recordId);
  462. notifications.records.saved();
  463. } else {
  464. console.error("❌ [useChat] Error al guardar reporte:", response.status);
  465. notifications.records.saveError();
  466. }
  467. } catch (error) {
  468. console.error("❌ [useChat] Error al generar reporte para cita:", error);
  469. notifications.records.generateError();
  470. } finally {
  471. setIsSchedulingFromAlert(false);
  472. }
  473. };
  474. const dismissMedicalAlertBanner = () => {
  475. setShowMedicalAlertBanner(false);
  476. };
  477. const dismissCrisisBanner = () => {
  478. setShowCrisisBanner(false);
  479. };
  480. return {
  481. messages,
  482. messageCount,
  483. isLimitReached,
  484. isGeneratingReport,
  485. generatedReport,
  486. inputMessage,
  487. setInputMessage,
  488. isLoading,
  489. reportGenerated,
  490. showSuggestions,
  491. showCompletedBanner,
  492. remainingMessages,
  493. isLastMessage,
  494. sendMessage,
  495. resetChat,
  496. handleResetWithReport,
  497. dismissCompletedBanner,
  498. setGeneratedReport,
  499. currentSuggestions,
  500. showDynamicSuggestions,
  501. setShowDynamicSuggestions,
  502. inputDisabledForSuggestions,
  503. setInputDisabledForSuggestions,
  504. medicalAlertDetected,
  505. showMedicalAlertBanner,
  506. isSchedulingFromAlert,
  507. handleScheduleFromAlert,
  508. dismissMedicalAlertBanner,
  509. crisisDetected,
  510. showCrisisBanner,
  511. dismissCrisisBanner,
  512. };
  513. };;