Pārlūkot izejas kodu

implement streaming (hopefully i don't regret it)

Matthew Trejo 2 mēneši atpakaļ
vecāks
revīzija
fd59c3f901
2 mainītis faili ar 254 papildinājumiem un 165 dzēšanām
  1. 149 89
      src/app/api/chat/route.ts
  2. 105 76
      src/hooks/useChat.ts

+ 149 - 89
src/app/api/chat/route.ts

@@ -99,11 +99,11 @@ export async function POST(request: NextRequest) {
     });
 
     try {
-      // Llamada a OpenRouter
+      // Llamada a OpenRouter con STREAMING habilitado
       const openRouterStartTime = Date.now();
-      console.log("🤖 [API] Iniciando llamada a OpenRouter...");
+      console.log("🤖 [API] Iniciando llamada a OpenRouter con streaming...");
 
-      const completion = await openai.chat.completions.create({
+      const stream = await openai.chat.completions.create({
         model: OPENROUTER_MODEL,
         messages: [
           {
@@ -190,100 +190,160 @@ RECUERDA: Eres un asistente médico virtual, NO un asistente general. Tu especia
           },
           ...conversationHistory,
         ],
-        max_tokens: 3000, // Espero no olvidarme de cambiar esto
+        max_tokens: 3000,
         temperature: 0.7,
+        stream: true, // 🔥 STREAMING HABILITADO
       });
 
-      const openRouterTime = Date.now() - openRouterStartTime;
-      console.log("✅ [API] Respuesta de OpenRouter recibida en:", openRouterTime, "ms");
+      console.log("📡 [API] Stream iniciado, comenzando a procesar chunks...");
+
+      // Crear un ReadableStream para enviar datos al cliente
+      const encoder = new TextEncoder();
+      let fullResponse = "";
+      let lastSentLength = 0;
+
+      const readableStream = new ReadableStream({
+        async start(controller) {
+          try {
+            for await (const chunk of stream) {
+              const content = chunk.choices[0]?.delta?.content || "";
+              if (content) {
+                fullResponse += content;
+                
+                // Intentar extraer solo el contenido del campo "response" del JSON en tiempo real
+                // Buscar el patrón: "response": "TEXTO AQUI"
+                const responseMatch = fullResponse.match(/"response"\s*:\s*"([\s\S]*?)(?:"|$)/);
+                
+                if (responseMatch) {
+                  // Decodificar secuencias de escape JSON
+                  const extractedText = responseMatch[1]
+                    .replace(/\\n/g, '\n')
+                    .replace(/\\r/g, '\r')
+                    .replace(/\\t/g, '\t')
+                    .replace(/\\"/g, '"')
+                    .replace(/\\\\/g, '\\');
+                  
+                  // Solo enviar el texto nuevo (no repetir lo que ya enviamos)
+                  if (extractedText.length > lastSentLength) {
+                    const newContent = extractedText.substring(lastSentLength);
+                    const data = JSON.stringify({ type: "content", content: newContent });
+                    controller.enqueue(encoder.encode(`data: ${data}\n\n`));
+                    lastSentLength = extractedText.length;
+                  }
+                }
+              }
+            }
 
-      const assistantResponse = completion.choices[0]?.message?.content;
-      console.log("📝 [API] Contenido de respuesta:", {
-        hasContent: !!assistantResponse,
-        contentLength: assistantResponse?.length || 0,
-        isEmpty: !assistantResponse || assistantResponse.trim() === ""
-      });
+            const openRouterTime = Date.now() - openRouterStartTime;
+            console.log("✅ [API] Stream completado en:", openRouterTime, "ms");
+            console.log("📝 [API] Respuesta completa recibida, longitud:", fullResponse.length);
+
+            // Procesar la respuesta completa para extraer metadatos
+            if (!fullResponse || fullResponse.trim() === "") {
+              console.log("⚠️ [API] Respuesta vacía de OpenRouter, enviando fallback");
+              const fallbackData = JSON.stringify({
+                type: "metadata",
+                medicalAlert: "NO_AGENDAR",
+                suggestions: [
+                  {
+                    title: "Síntomas de Gripe",
+                    emoji: "🤒",
+                    prompt: "¿Cuáles son los síntomas más comunes de la gripe y cómo diferenciarla de un resfriado?"
+                  },
+                  {
+                    title: "Fortalecer Inmunidad",
+                    emoji: "🛡️",
+                    prompt: "¿Qué alimentos y hábitos me ayudan a fortalecer mi sistema inmunológico?"
+                  },
+                  {
+                    title: "Cuándo Consultar",
+                    emoji: "🩺",
+                    prompt: "¿Cuándo debo consultar a un médico por síntomas de gripe o resfriado?"
+                  }
+                ]
+              });
+              controller.enqueue(encoder.encode(`data: ${fallbackData}\n\n`));
+              controller.enqueue(encoder.encode('data: [DONE]\n\n'));
+              controller.close();
+              return;
+            }
 
-      if (!assistantResponse || assistantResponse.trim() === "") {
-        console.log("⚠️ [API] Respuesta vacía de OpenRouter, usando fallback");
-        const fallbackResponse = {
-          response: "Gracias por tu consulta. 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?",
-          medicalAlert: "NO_AGENDAR" as const,
-          suggestions: [
-            {
-              title: "Síntomas de Gripe",
-              emoji: "🤒",
-              prompt: "¿Cuáles son los síntomas más comunes de la gripe y cómo diferenciarla de un resfriado?"
-            },
-            {
-              title: "Fortalecer Inmunidad",
-              emoji: "🛡️",
-              prompt: "¿Qué alimentos y hábitos me ayudan a fortalecer mi sistema inmunológico?"
-            },
-            {
-              title: "Cuándo Consultar",
-              emoji: "🩺",
-              prompt: "¿Cuándo debo consultar a un médico por síntomas de gripe o resfriado?"
+            try {
+              console.log("🔄 [API] Procesando metadatos JSON...");
+              // Limpiar respuesta de posibles bloques de código markdown
+              let cleanedResponse = fullResponse.trim();
+
+              if (cleanedResponse.startsWith('```json')) {
+                cleanedResponse = cleanedResponse.replace(/^```json\s*/, '').replace(/\s*```$/, '');
+                console.log("🧹 [API] Removidos bloques de código markdown");
+              }
+
+              const parsedResponse = JSON.parse(cleanedResponse);
+              console.log("✅ [API] JSON parseado exitosamente:", {
+                hasResponse: !!parsedResponse.response,
+                medicalAlert: parsedResponse.medicalAlert,
+                suggestionsCount: parsedResponse.suggestions?.length || 0
+              });
+
+              // Enviar metadatos al final del stream
+              const metadataEvent = JSON.stringify({
+                type: "metadata",
+                medicalAlert: parsedResponse.medicalAlert || "NO_AGENDAR",
+                suggestions: parsedResponse.suggestions || []
+              });
+              controller.enqueue(encoder.encode(`data: ${metadataEvent}\n\n`));
+
+            } catch (parseError) {
+              console.log("❌ [API] Error parseando JSON:", parseError);
+              console.log("📄 [API] Contenido que falló al parsear:", fullResponse.substring(0, 200) + '...');
+              
+              // Enviar metadatos por defecto si falla el parsing
+              const fallbackMetadata = JSON.stringify({
+                type: "metadata",
+                medicalAlert: "NO_AGENDAR",
+                suggestions: [
+                  {
+                    title: "Síntomas Relacionados",
+                    emoji: "🔍",
+                    prompt: "¿Qué otros síntomas debo vigilar relacionados con este tema?"
+                  },
+                  {
+                    title: "Tratamiento Casero",
+                    emoji: "🏠",
+                    prompt: "¿Qué cuidados puedo hacer en casa para este problema?"
+                  },
+                  {
+                    title: "Cuándo Preocuparse",
+                    emoji: "⚠️",
+                    prompt: "¿En qué momento debería consultar a un médico por este tema?"
+                  }
+                ]
+              });
+              controller.enqueue(encoder.encode(`data: ${fallbackMetadata}\n\n`));
             }
-          ]
-        };
-        return NextResponse.json(fallbackResponse);
-      }
-
-      try {
-        console.log("🔄 [API] Procesando respuesta JSON...");
-        // Limpiar respuesta de posibles bloques de código markdown
-        let cleanedResponse = assistantResponse.trim();
-
-        // Remover bloques de código markdown si existen
-        if (cleanedResponse.startsWith('```json')) {
-          cleanedResponse = cleanedResponse.replace(/^```json\s*/, '').replace(/\s*```$/, '');
-          console.log("🧹 [API] Removidos bloques de código markdown");
-        }
 
-        const parsedResponse = JSON.parse(cleanedResponse);
-        console.log("✅ [API] JSON parseado exitosamente:", {
-          hasResponse: !!parsedResponse.response,
-          medicalAlert: parsedResponse.medicalAlert,
-          suggestionsCount: parsedResponse.suggestions?.length || 0
-        });
-
-        // Validar estructura de respuesta
-        if (parsedResponse.response && parsedResponse.medicalAlert && parsedResponse.suggestions) {
-          const totalTime = Date.now() - requestStartTime;
-          console.log("🎉 [API] Respuesta exitosa enviada en:", totalTime, "ms");
-          return NextResponse.json(parsedResponse);
-        } else {
-          throw new Error("Estructura de respuesta inválida");
+            // Enviar señal de finalización
+            controller.enqueue(encoder.encode('data: [DONE]\n\n'));
+            controller.close();
+
+            const totalTime = Date.now() - requestStartTime;
+            console.log("🎉 [API] Stream finalizado exitosamente en:", totalTime, "ms");
+
+          } catch (streamError) {
+            console.error("💥 [API] Error durante streaming:", streamError);
+            controller.error(streamError);
+          }
         }
-      } catch (parseError) {
-        console.log("❌ [API] Error parseando JSON:", parseError);
-        console.log("📄 [API] Contenido que falló al parsear:", assistantResponse.substring(0, 200) + '...');
-        console.log("🔄 [API] Usando respuesta como texto plano");
-
-        const fallbackStructuredResponse = {
-          response: assistantResponse.trim(),
-          medicalAlert: "NO_AGENDAR" as const,
-          suggestions: [
-            {
-              title: "Síntomas Relacionados",
-              emoji: "🔍",
-              prompt: "¿Qué otros síntomas debo vigilar relacionados con este tema?"
-            },
-            {
-              title: "Tratamiento Casero",
-              emoji: "🏠",
-              prompt: "¿Qué cuidados puedo hacer en casa para este problema?"
-            },
-            {
-              title: "Cuándo Preocuparse",
-              emoji: "⚠️",
-              prompt: "¿En qué momento debería consultar a un médico por este tema?"
-            }
-          ]
-        };
-        return NextResponse.json(fallbackStructuredResponse);
-      }
+      });
+
+      // Retornar Response con el stream
+      return new Response(readableStream, {
+        headers: {
+          'Content-Type': 'text/event-stream',
+          'Cache-Control': 'no-cache',
+          'Connection': 'keep-alive',
+        },
+      });
     } catch (openRouterError) {
       const openRouterTime = Date.now() - requestStartTime;
       console.error("💥 [API] Error con OpenRouter después de", openRouterTime, "ms:", openRouterError);

+ 105 - 76
src/hooks/useChat.ts

@@ -169,7 +169,7 @@ export const useChat = () => {
     console.log("⏱️ [CHAT] Iniciando petición a API en:", new Date().toISOString());
 
     try {
-      // Llamada a OpenRouter API
+      // Llamada a OpenRouter API con STREAMING
       console.log("📡 [CHAT] Enviando petición a /api/chat...");
       const response = await fetch("/api/chat", {
         method: "POST",
@@ -183,73 +183,125 @@ export const useChat = () => {
       });
       
       const responseTime = Date.now() - startTime;
-      console.log("✅ [CHAT] Respuesta recibida en:", responseTime, "ms");
+      console.log("✅ [CHAT] Conexión establecida en:", responseTime, "ms");
       console.log("📋 [CHAT] Status de respuesta:", response.status, response.statusText);
 
       if (response.ok) {
-        console.log("🔄 [CHAT] Procesando respuesta JSON...");
-        const data: ChatResponse = await response.json();
-        console.log("📦 [CHAT] Datos recibidos:", {
-          responseLength: data.response?.length || 0,
-          medicalAlert: data.medicalAlert,
-          suggestionsCount: data.suggestions?.length || 0
-        });
-
-        // Detectar alerta médica
-        if (data.medicalAlert && data.medicalAlert !== "NO_AGENDAR" && !medicalAlertDetected) {
-          console.log("🚨 [CHAT] Alerta médica detectada:", data.medicalAlert);
-          setMedicalAlertDetected(data.medicalAlert);
-          setShowMedicalAlertBanner(true);
-        }
-
-        // Crear mensaje del asistente con streaming
+        console.log("🔄 [CHAT] Procesando stream en tiempo real...");
+        
+        // Crear mensaje del asistente vacío para ir llenándolo
         const assistantMessage: Message = {
           role: "assistant",
           content: "",
           timestamp: new Date(),
           isStreaming: true,
-          medicalAlert: data.medicalAlert,
-          suggestions: data.suggestions,
         };
 
         setMessages((prev) => [...prev, assistantMessage]);
-        console.log("🎬 [CHAT] Iniciando streaming de respuesta...");
-
-        // Simular streaming de la respuesta
-        streamText(
-          data.response,
-          (streamedText) => {
-            setMessages((prev) =>
-              prev.map((msg, index) =>
-                index === prev.length - 1 && msg.role === "assistant"
-                  ? { ...msg, content: streamedText }
-                  : msg
-              )
-            );
-          },
-          () => {
-            // Cuando termina el streaming
-            setMessages((prev) =>
-              prev.map((msg, index) =>
-                index === prev.length - 1 && msg.role === "assistant"
-                  ? { ...msg, isStreaming: false }
-                  : msg
-              )
-            );
+        console.log("🎬 [CHAT] Iniciando recepción de stream...");
+
+        // Procesar el stream
+        const reader = response.body?.getReader();
+        const decoder = new TextDecoder();
+        let buffer = "";
+        let fullContent = "";
+        let metadata: { medicalAlert?: string; suggestions?: SuggestedPrompt[] } = {};
+
+        if (!reader) {
+          throw new Error("No se pudo obtener el reader del stream");
+        }
+
+        try {
+          while (true) {
+            const { done, value } = await reader.read();
+            
+            if (done) {
+              console.log("✅ [CHAT] Stream completado");
+              break;
+            }
+
+            // Decodificar el chunk
+            buffer += decoder.decode(value, { stream: true });
             
-            // Mostrar sugerencias dinámicas después del streaming si no es el último mensaje
-            if (messageCount + 1 < MAX_MESSAGES) {
-              setCurrentSuggestions(data.suggestions);
-              setShowDynamicSuggestions(true);
-              setInputDisabledForSuggestions(true);
-              
-              // Habilitar el input después de 3 segundos para dar tiempo a leer las sugerencias
-              setTimeout(() => {
-                setInputDisabledForSuggestions(false);
-              }, 3000);
+            // Procesar eventos SSE (formato: data: {json}\n\n)
+            const lines = buffer.split('\n\n');
+            buffer = lines.pop() || ""; // Guardar la última línea incompleta
+
+            for (const line of lines) {
+              if (line.startsWith('data: ')) {
+                const data = line.slice(6); // Remover "data: "
+                
+                if (data === '[DONE]') {
+                  console.log("� [CHAT] Recibida señal de finalización");
+                  break;
+                }
+
+                try {
+                  const parsed = JSON.parse(data);
+                  
+                  if (parsed.type === 'content') {
+                    // Acumular contenido y actualizar en tiempo real
+                    fullContent += parsed.content;
+                    setMessages((prev) =>
+                      prev.map((msg, index) =>
+                        index === prev.length - 1 && msg.role === "assistant"
+                          ? { ...msg, content: fullContent }
+                          : msg
+                      )
+                    );
+                  } else if (parsed.type === 'metadata') {
+                    // Guardar metadatos
+                    metadata = {
+                      medicalAlert: parsed.medicalAlert,
+                      suggestions: parsed.suggestions
+                    };
+                    console.log("📦 [CHAT] Metadatos recibidos:", metadata);
+                  }
+                } catch (parseError) {
+                  console.warn("⚠️ [CHAT] Error parseando evento SSE:", parseError);
+                }
+              }
             }
           }
+        } finally {
+          reader.releaseLock();
+        }
+
+        const totalTime = Date.now() - startTime;
+        console.log("✅ [CHAT] Respuesta completa procesada en:", totalTime, "ms");
+
+        // Detectar alerta médica
+        if (metadata.medicalAlert && metadata.medicalAlert !== "NO_AGENDAR" && !medicalAlertDetected) {
+          console.log("🚨 [CHAT] Alerta médica detectada:", metadata.medicalAlert);
+          setMedicalAlertDetected(metadata.medicalAlert as MedicalAlert);
+          setShowMedicalAlertBanner(true);
+        }
+
+        // Marcar mensaje como completado y agregar metadatos
+        setMessages((prev) =>
+          prev.map((msg, index) =>
+            index === prev.length - 1 && msg.role === "assistant"
+              ? {
+                  ...msg,
+                  isStreaming: false,
+                  medicalAlert: metadata.medicalAlert as MedicalAlert | undefined,
+                  suggestions: metadata.suggestions
+                }
+              : msg
+          )
         );
+
+        // Mostrar sugerencias dinámicas después del streaming si no es el último mensaje
+        if (messageCount + 1 < MAX_MESSAGES && metadata.suggestions) {
+          setCurrentSuggestions(metadata.suggestions);
+          setShowDynamicSuggestions(true);
+          setInputDisabledForSuggestions(true);
+          
+          // Habilitar el input después de 3 segundos para dar tiempo a leer las sugerencias
+          setTimeout(() => {
+            setInputDisabledForSuggestions(false);
+          }, 3000);
+        }
       } else {
         console.log("❌ [CHAT] Error en respuesta de API - Status:", response.status);
         const errorText = await response.text();
@@ -479,27 +531,4 @@ export const useChat = () => {
     handleScheduleFromAlert,
     dismissMedicalAlertBanner,
   };
-};
-
-// Función para simular streaming de texto
-const streamText = (
-  fullText: string,
-  onUpdate: (text: string) => void,
-  onComplete: () => void
-) => {
-  let currentIndex = 0;
-  const words = fullText.split(" ");
-
-  const interval = setInterval(() => {
-    if (currentIndex < words.length) {
-      const currentText = words.slice(0, currentIndex + 1).join(" ");
-      onUpdate(currentText);
-      currentIndex++;
-    } else {
-      clearInterval(interval);
-      onComplete();
-    }
-  }, 50); // Velocidad del streaming
-
-  return () => clearInterval(interval);
 };